Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton ad295d58fa feat(v2): expose canonical schema contracts 2026-07-06 15:22:47 -04:00
102 changed files with 784 additions and 2417 deletions
+30 -28
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
@@ -93,7 +93,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.15",
"version": "1.17.14",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -141,7 +141,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -155,7 +155,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -191,7 +191,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -218,7 +218,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -240,7 +240,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -264,7 +264,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -284,7 +284,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.15",
"version": "1.17.14",
"bin": {
"opencode": "./bin/opencode",
},
@@ -378,7 +378,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -432,7 +432,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -446,7 +446,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"effect": "catalog:",
},
@@ -458,7 +458,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -490,7 +490,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -506,7 +506,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -537,7 +537,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -556,7 +556,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.15",
"version": "1.17.14",
"bin": {
"opencode": "./bin/opencode",
},
@@ -687,9 +687,10 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:",
@@ -752,6 +753,7 @@
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"effect": "catalog:",
},
@@ -763,7 +765,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -778,7 +780,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -793,7 +795,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -837,7 +839,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -850,7 +852,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -883,7 +885,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -902,7 +904,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -943,7 +945,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -970,7 +972,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1021,7 +1023,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.15",
"version": "1.17.14",
"description": "",
"type": "module",
"exports": {
+16 -61
View File
@@ -9,7 +9,7 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
@@ -32,7 +32,7 @@ import { CommandProvider, useCommand, type CommandOption } from "@/context/comma
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
@@ -52,10 +52,9 @@ import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { createSessionLineage } from "@/pages/session/session-lineage"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -89,14 +88,10 @@ const SessionRoute = () => {
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return (
<SessionRouteErrorBoundary sessionID={params.id}>
<SessionPage />
</SessionRouteErrorBoundary>
)
return <SessionPage />
}
function TargetServerRoute(props: ParentProps) {
const TargetSessionRoute = () => {
const params = useParams<{ serverKey: string; id: string }>()
const global = useGlobal()
const conn = createMemo(() => {
@@ -110,47 +105,14 @@ function TargetServerRoute(props: ParentProps) {
// re-resolves reactively instead); both rely on this key for server changes.
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
<ServerSyncProvider server={conn}>
<TargetSessionRouteContent />
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
const TargetSessionRoute = () => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)
function LegacyTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<TargetServerRoute>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<LegacyTargetSessionRedirect />
</SessionRouteErrorBoundary>
</TargetServerRoute>
)
}
function LegacyTargetSessionRedirect() {
const params = useParams<{ id: string }>()
const navigate = useNavigate()
const sync = useServerSync()
const current = createSessionLineage(
() => params.id,
() => sync().session.lineage,
)
createEffect(() => {
const directory = current()?.session.directory
if (!directory) return
navigate(legacySessionHref(directory, params.id), { replace: true })
})
return null
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
@@ -224,7 +186,7 @@ declare global {
deepLinks?: string[]
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
}
}
@@ -358,8 +320,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
<MetaProvider>
<Font />
<ThemeProvider
onThemeApplied={(_, mode, scheme) => {
void window.api?.setTitlebar?.({ mode, scheme })
onThemeApplied={(_, mode) => {
void window.api?.setTitlebar?.({ mode })
}}
>
<LanguageProvider locale={props.locale}>
@@ -580,14 +542,7 @@ function Routes(props: { serverScoped?: JSX.Element }) {
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
)}
>
<Show when={!settings.general.newLayoutDesigns()}>
{
<>
<Route path="/" component={LegacyHome} />
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
</>
}
</Show>
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
@@ -595,15 +550,15 @@ function Routes(props: { serverScoped?: JSX.Element }) {
</Route>
<Show when={settings.general.newLayoutDesigns()}>
<Route path="/" component={NewHome} />
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
</Show>
<Route path="/new-session" component={DraftRoute} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
</>
)
}
function NewLayoutLegacySessionRedirect() {
function LegacyTargetSessionRoute() {
const server = useServer()
const tabs = useTabs()
const params = useParams<{ id: string }>()
@@ -1,325 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useNavigate } from "@solidjs/router"
import { createMemo, onCleanup } from "solid-js"
import { useCommand, type CommandOption } from "@/context/command"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
export type CommandPaletteEntry = {
id: string
type: "command" | "file" | "session"
title: string
description?: string
keybind?: string
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
updated?: number
}
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
const seen = new Set<string>()
return items.filter((item) => {
if (seen.has(item.id)) return false
seen.add(item.id)
return true
})
}
export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
return {
id: "file:" + path,
type: "file",
title: path,
category,
path,
}
}
export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
const file = useFile()
const layout = useLayout()
const { tabs, view } = useSessionLayout()
return (path: string) => {
const value = file.tab(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
onOpenFile?.(path)
tabs().setActive(value)
}
}
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()()
const serverSync = useServerSync()
const { params, tabs } = useSessionLayout()
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
const state = { cleanup: undefined as (() => void) | void, committed: false }
const filesOnly = () => props.filesOnly?.() ?? false
const allowedCommands = createMemo(() => {
if (filesOnly()) return []
return command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const commandEntries = createMemo(() => {
const category = language.t("palette.group.commands")
return allowedCommands().map((option) => createCommandEntry(option, category))
})
const preferredCommandEntries = createMemo(() => {
const all = allowedCommands()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
const tabState = createSessionTabs({
tabs,
pathFromTab: file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
})
const recentFileEntries = createMemo(() => {
const all = tabState.openedTabs()
const active = tabState.activeFileTab()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = language.t("palette.group.files")
return order
.map((item) => file.pathFromTab(item))
.filter((path): path is string => {
if (!path || seen.has(path)) return false
seen.add(path)
return true
})
.slice(0, ENTRY_LIMIT)
.map((path) => createCommandPaletteFileEntry(path, category))
})
const rootFileEntries = createMemo(() => {
const category = language.t("palette.group.files")
return file.tree
.children("")
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
.slice(0, ENTRY_LIMIT)
.map((path) => createCommandPaletteFileEntry(path, category))
})
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return undefined
return layout.projects.list().find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const sessions = createSessionEntries({
workspaces,
label: (directory) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
},
load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
const highlight = (item: CommandPaletteEntry | undefined) => {
state.cleanup?.()
state.cleanup = undefined
if (item?.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const select = (item: CommandPaletteEntry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
openFile(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return {
language,
file,
commandEntries,
preferredCommandEntries,
recentFileEntries,
rootFileEntries,
sessions,
highlight,
select,
close: () => dialog.close(),
}
}
function createCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
return {
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
}
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
untitled: () => string
category: () => string
}) {
const state: {
token: number
inflight: Promise<CommandPaletteEntry[]> | undefined
cached: CommandPaletteEntry[] | undefined
} = { token: 0, inflight: undefined, cached: undefined }
return (text: string) => {
if (!text.trim()) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as CommandPaletteEntry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as CommandPaletteEntry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props
.load(directory)
.then((result) =>
(result.data ?? [])
.filter((session) => !!session?.id)
.map((session) => ({
id: session.id,
title: session.title ?? props.untitled(),
description,
directory,
archived: session.time?.archived,
updated: session.time?.updated,
})),
)
.catch(() => [] as SessionEntryInput[])
}),
)
.then((results) => {
if (state.token !== current) return [] as CommandPaletteEntry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, props.category()))
state.cached = next
return next
})
.catch(() => [] as CommandPaletteEntry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
}
type SessionEntryInput = {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}
function createSessionEntry(input: SessionEntryInput, category: string): CommandPaletteEntry {
return {
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
}
}
@@ -1,220 +0,0 @@
.command-palette-v2 {
overflow: hidden;
}
/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
top stays put while the content-driven height grows and shrinks. */
[data-component="dialog-v2"]:has(.command-palette-v2) {
align-items: flex-start;
}
[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
width: min(calc(100vw - 24px), 640px);
height: auto;
min-height: 280px;
max-height: min(calc(100vh - 96px), 480px);
margin-top: max(48px, calc((100vh - 480px) / 2));
border-radius: 12px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-floating);
}
.command-palette-v2-body {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 0;
padding: 0;
}
.command-palette-v2-search {
flex-shrink: 0;
padding: 6px;
}
.command-palette-v2-search [data-component="text-input-v2"] {
width: 100%;
height: 36px;
border-radius: 6px;
outline: 0;
background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
box-shadow: none;
transition:
background-color 120ms ease-in-out,
box-shadow 120ms ease-in-out;
}
.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
background: var(--v2-background-bg-layer-02);
box-shadow: none;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
border-radius: 6px;
background: transparent;
gap: 8px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
padding-left: 12px;
}
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
border-radius: 6px;
background: transparent;
}
.command-palette-v2-scroll {
min-height: 0;
flex: 1;
}
.command-palette-v2-results {
display: flex;
flex-direction: column;
gap: 16px;
padding: 6px 6px 8px;
}
.command-palette-v2-group {
display: flex;
flex-direction: column;
gap: 1px;
}
.command-palette-v2-group-title {
margin: 6px 0;
padding: 0 12px;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
user-select: none;
}
.command-palette-v2-row {
display: flex;
width: 100%;
height: 36px;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--v2-text-text-base);
text-align: left;
cursor: default;
scroll-margin: 6px 0;
}
.command-palette-v2-row[data-active] {
background: var(--v2-overlay-simple-overlay-hover);
}
.command-palette-v2-row:focus-visible {
background: var(--v2-overlay-simple-overlay-hover);
outline: none;
}
.command-palette-v2-row-main {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 8px;
}
.command-palette-v2-row-icon {
flex-shrink: 0;
color: var(--v2-icon-icon-muted);
}
.command-palette-v2-row-text {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.command-palette-v2-title {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: 16px;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-description,
.command-palette-v2-meta {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-meta {
flex-shrink: 0;
}
.command-palette-v2-file-path {
display: flex;
min-width: 0;
align-items: baseline;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
}
.command-palette-v2-file-dir {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
font-weight: 440;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-v2-file-name {
flex-shrink: 0;
color: var(--v2-text-text-base);
font-weight: 530;
white-space: nowrap;
}
.command-palette-v2-state {
display: grid;
min-height: 120px;
place-items: center;
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: 16px;
letter-spacing: -0.04px;
}
@media (max-width: 640px) {
.command-palette-v2-row-text {
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.command-palette-v2-description {
max-width: 100%;
}
}
@@ -1,221 +0,0 @@
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { formatKeybindParts } from "@/context/command"
import { useLanguage } from "@/context/language"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
import "./dialog-command-palette-v2.css"
function groups(entries: CommandPaletteEntry[]) {
const map = new Map<string, CommandPaletteEntry[]>()
for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
}
function matchesEntry(entry: CommandPaletteEntry, query: string) {
const value = query.toLowerCase()
return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
}
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const [query, setQuery] = createSignal("")
const [active, setActive] = createSignal(0)
const loadItems = async (text: string) => {
const q = text.trim()
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
const category = palette.language.t("palette.group.files")
return [
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
...nextSessions.filter((entry) => matchesEntry(entry, q)),
...files.map((path) => createCommandPaletteFileEntry(path, category)),
]
}
const [entries] = createResource(query, loadItems, { initialValue: [] as CommandPaletteEntry[] })
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
const groupedEntries = createMemo(() => groups(visibleEntries()))
const activeEntry = createMemo(() => visibleEntries()[active()])
createEffect(() => {
query()
visibleEntries()
setActive(0)
})
createEffect(() => {
palette.highlight(activeEntry())
})
let resultsRef: HTMLDivElement | undefined
const move = (delta: -1 | 1) => {
const count = visibleEntries().length
if (count === 0) return
setActive((index) => (index + delta + count) % count)
requestAnimationFrame(() => {
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
})
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault()
move(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
move(-1)
return
}
if (event.key === "Enter") {
event.preventDefault()
palette.select(activeEntry())
return
}
if (event.key === "Escape") {
event.preventDefault()
palette.close()
}
}
return (
<Dialog class="command-palette-v2" size="large">
<DialogBody class="command-palette-v2-body">
<div class="command-palette-v2-search">
<TextInputV2
value={query()}
autofocus
autocomplete="off"
spellcheck={false}
appearance="large"
placeholder={palette.language.t("palette.search.placeholder")}
leadingIcon={<Icon name="magnifying-glass" />}
onInput={(event) => setQuery(event.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
</div>
<ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
<div class="command-palette-v2-results" role="listbox">
<Show
when={visibleEntries().length > 0}
fallback={
<div class="command-palette-v2-state">
{entries.loading ? palette.language.t("common.loading") : palette.language.t("palette.empty")}
</div>
}
>
<For each={groupedEntries()}>
{(group) => (
<div class="command-palette-v2-group">
<Show when={group.category}>
<div class="command-palette-v2-group-title">{group.category}</div>
</Show>
<For each={group.entries}>
{(item) => (
<PaletteRow
item={item}
active={activeEntry()?.id === item.id}
language={palette.language}
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
onSelect={() => palette.select(item)}
/>
)}
</For>
</div>
)}
</For>
</Show>
</div>
</ScrollView>
</DialogBody>
</Dialog>
)
}
function PaletteRow(props: {
item: CommandPaletteEntry
active: boolean
language: ReturnType<typeof useLanguage>
onActive: () => void
onSelect: () => void
}) {
return (
<button
type="button"
class="command-palette-v2-row"
role="option"
aria-selected={props.active}
data-active={props.active ? "" : undefined}
onMouseMove={(event) => {
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
if (event.movementX === 0 && event.movementY === 0) return
props.onActive()
}}
onMouseDown={(event) => event.preventDefault()}
onClick={props.onSelect}
>
<Switch
fallback={
<div class="command-palette-v2-row-main">
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
<div class="command-palette-v2-file-path">
<span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
<span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
</div>
</div>
}
>
<Match when={props.item.type === "command"}>
<div class="command-palette-v2-row-main">
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title">{props.item.title}</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description">{props.item.description}</span>
</Show>
</div>
</div>
<Show when={props.item.keybind}>
<KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
</Show>
</Match>
<Match when={props.item.type === "session"}>
<div class="command-palette-v2-row-main">
<Icon name="status" class="command-palette-v2-row-icon" />
<div class="command-palette-v2-row-text">
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.title}
</span>
<Show when={props.item.description}>
<span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
{props.item.description}
</span>
</Show>
</div>
</div>
<Show when={props.item.updated}>
<span class="command-palette-v2-meta">
{getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
</span>
</Show>
</Match>
</Switch>
</button>
)
}
@@ -1,84 +1,329 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time"
import {
createCommandPaletteFileEntry,
createCommandPaletteFileOpener,
createCommandPaletteModel,
uniqueCommandPaletteEntries,
type CommandPaletteEntry,
} from "./command-palette"
const DialogSelectFileV2 = lazy(() =>
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
)
const DialogCommandPaletteV2 = lazy(() =>
import("./dialog-command-palette-v2").then((module) => ({ default: module.DialogCommandPaletteV2 })),
)
type EntryType = "command" | "file" | "session"
type Entry = {
id: string
type: EntryType
title: string
description?: string
keybind?: string
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
updated?: number
}
type DialogSelectFileMode = "all" | "files"
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
const uniqueEntries = (items: Entry[]) => {
const seen = new Set<string>()
const out: Entry[] = []
for (const item of items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
return out
}
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
})
const createFileEntry = (path: string, category: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category,
path,
})
const createSessionEntry = (
input: {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
},
category: string,
): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
})
function createCommandEntries(props: {
filesOnly: () => boolean
command: ReturnType<typeof useCommand>
language: ReturnType<typeof useLanguage>
}) {
const allowed = createMemo(() => {
if (props.filesOnly()) return []
return props.command.options.filter(
(option) =>
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const list = createMemo(() => {
const category = props.language.t("palette.group.commands")
return allowed().map((option) => createCommandEntry(option, category))
})
const picks = createMemo(() => {
const all = allowed()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = props.language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
return { allowed, list, picks }
}
function createFileEntries(props: {
file: ReturnType<typeof useFile>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
language: ReturnType<typeof useLanguage>
}) {
const tabState = createSessionTabs({
tabs: props.tabs,
pathFromTab: props.file.pathFromTab,
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
})
const recent = createMemo(() => {
const all = tabState.openedTabs()
const active = tabState.activeFileTab()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = props.language.t("palette.group.files")
const items: Entry[] = []
for (const item of order) {
const path = props.file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(createFileEntry(path, category))
}
return items.slice(0, ENTRY_LIMIT)
})
const root = createMemo(() => {
const category = props.language.t("palette.group.files")
const nodes = props.file.tree.children("")
const paths = nodes
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
})
return { recent, root }
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
serverSDK: ServerSDK
language: ReturnType<typeof useLanguage>
}) {
const state: {
token: number
inflight: Promise<Entry[]> | undefined
cached: Entry[] | undefined
} = {
token: 0,
inflight: undefined,
cached: undefined,
}
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as Entry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as Entry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props.serverSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? props.language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
updated: s.time?.updated,
})),
)
.catch(
() =>
[] as {
id: string
title: string
description: string
directory: string
archived?: number
updated?: number
}[],
)
}),
)
.then((results) => {
if (state.token !== current) return [] as Entry[]
const seen = new Set<string>()
const category = props.language.t("command.category.session")
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, category))
state.cached = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
return { sessions }
}
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const filesOnly = () => props.mode === "files"
if (!filesOnly() && settings.general.newLayoutDesigns()) {
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
}
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
}
return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
}
function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
const language = useLanguage()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()
const { params } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
return (
<DialogSelectFileV2
server={serverSDK().server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
openFile(result)
}}
/>
)
}
function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
const palette = createCommandPaletteModel(props)
const serverSync = useServerSync()
const { params, tabs, view } = useSessionLayout()
const filesOnly = () => props.mode === "files"
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const commandEntries = createCommandEntries({ filesOnly, command, language })
const fileEntries = createFileEntries({ file, tabs, language })
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync().data.path.home)
const label = (directory: string) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync().child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
}
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
const items = async (text: string) => {
const query = text.trim()
setGrouped(query.length > 0)
if (!query && props.filesOnly()) {
const loaded = palette.file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
if (!query && filesOnly()) {
const loaded = file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : file.tree.list("")
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
if (loaded || next.length > 0) {
void pending
@@ -86,24 +331,79 @@ function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?:
}
await pending
return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
}
if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
if (props.filesOnly()) {
const files = await palette.file.searchFiles(query)
const category = palette.language.t("palette.group.files")
return files.map((path) => createCommandPaletteFileEntry(path, category))
if (filesOnly()) {
const files = await file.searchFiles(query)
const category = language.t("palette.group.files")
return files.map((path) => createFileEntry(path, category))
}
const [files, nextSessions] = await Promise.all([
palette.file.searchFiles(query),
Promise.resolve(palette.sessions(query)),
])
const category = palette.language.t("palette.group.files")
const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
return [...palette.commandEntries(), ...nextSessions, ...entries]
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const category = language.t("palette.group.files")
const entries = files.map((path) => createFileEntry(path, category))
return [...commandEntries.list(), ...nextSessions, ...entries]
}
const handleMove = (item: Entry | undefined) => {
state.cleanup?.()
if (!item) return
if (item.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const open = (path: string) => {
const value = file.tab(path)
void tabs().open(value)
void file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
props.onOpenFile?.(path)
tabs().setActive(value)
}
const handleSelect = (item: Entry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
open(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
return (
<DialogSelectFileV2
server={serverSDK().server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
onSelect={(result) => {
if (typeof result !== "string") return
open(result)
}}
/>
)
}
return (
@@ -111,21 +411,21 @@ function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?:
<List
class="px-3"
search={{
placeholder: props.filesOnly()
? palette.language.t("session.header.searchFiles")
: palette.language.t("palette.search.placeholder"),
placeholder: filesOnly()
? language.t("session.header.searchFiles")
: language.t("palette.search.placeholder"),
autofocus: true,
hideIcon: true,
}}
emptyMessage={palette.language.t("palette.empty")}
loadingMessage={palette.language.t("common.loading")}
emptyMessage={language.t("palette.empty")}
loadingMessage={language.t("common.loading")}
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
skipFilter={(item) => item.type === "file"}
groupBy={grouped() ? (item) => item.category : () => ""}
onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
onMove={handleMove}
onSelect={handleSelect}
>
{(item) => (
<Switch
@@ -152,7 +452,7 @@ function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?:
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
</Show>
</div>
</Match>
@@ -179,7 +479,7 @@ function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?:
</div>
<Show when={item.updated}>
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
{getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
{getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
</span>
</Show>
</div>
@@ -24,7 +24,6 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
@@ -93,7 +92,6 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
@@ -448,41 +446,26 @@ export function ModelSelectorPopoverV2(props: {
<MenuV2.RadioGroup value={current()}>
<For each={group.items}>
{(item) => (
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={isFree(item.provider.id, item.cost)}
v2
/>
}
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
>
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
</TooltipV2>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
)}
</For>
</MenuV2.RadioGroup>
+2 -35
View File
@@ -1,4 +1,4 @@
import { Show, type Component, type JSX } from "solid-js"
import { Show, type Component } from "solid-js"
import { useLanguage } from "@/context/language"
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
@@ -23,18 +23,7 @@ type ModelInfo = {
}
}
function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
return (
<div class="flex min-w-0 items-center gap-4">
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
</div>
)
}
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = (
props,
) => {
export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean }> = (props) => {
const language = useLanguage()
const sourceName = (model: ModelInfo) => {
const value = `${model.id} ${model.name}`.toLowerCase()
@@ -62,13 +51,6 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${sourceName(props.model)} ${props.model.name}${suffix}`
}
const name = () => {
const tags: Array<string> = []
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${props.model.name}${suffix}`
}
const inputs = () => {
if (props.model.capabilities) {
const input = props.model.capabilities.input
@@ -91,21 +73,6 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
: language.t("model.tooltip.reasoning.none")
}
const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() })
const contextLimit = () => props.model.limit.context.toLocaleString(language.intl())
if (props.v2) {
return (
<div class="flex w-[180px] flex-col gap-2">
<ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
<ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
<Show when={inputs()}>
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
</Show>
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
</div>
)
}
return (
<div class="flex flex-col gap-1 py-1">
@@ -440,6 +440,11 @@ export const SettingsGeneral: Component = () => {
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
@@ -466,6 +471,11 @@ export const SettingsGeneral: Component = () => {
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
@@ -422,6 +422,11 @@ export const SettingsGeneralV2: Component<{
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
@@ -449,6 +454,11 @@ export const SettingsGeneralV2: Component<{
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
/>
</SettingsRowV2>
@@ -246,23 +246,18 @@ export function TabNavItem(props: {
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span data-slot="project-avatar-slot" class="flex size-4 shrink-0 items-center justify-center">
<Show
when={props.session()}
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
}
>
{(session) => (
<Show when={props.session()}>
{(session) => (
<span data-slot="project-avatar-slot">
<SessionTabAvatar
project={project()}
directory={session().directory}
sessionId={session().id}
server={props.server}
/>
)}
</Show>
</span>
</span>
)}
</Show>
<span
ref={(el) => {
titleEl = el
@@ -40,7 +40,6 @@ function SessionTabSlot(props: {
let ref!: HTMLDivElement
const sdk = createMemo(() => props.serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource(
() => {
const ctx = props.serverCtx()
@@ -72,10 +71,8 @@ function SessionTabSlot(props: {
createEffect(() => {
const value = session()
if (!value) return
tabs.rememberSessionInfo(props.tab, value)
const current = sdk()
if (!current) return
if (!value || !current) return
createTabPromptState(tabs, props.tab, current.scope, {
dir: base64Encode(value.directory),
id: value.id,
@@ -89,7 +86,7 @@ function SessionTabSlot(props: {
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
classList={{ hidden: !session() && !missingSession() }}
>
<TabNavItem
ref={(el) => {
@@ -98,7 +95,7 @@ function SessionTabSlot(props: {
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
+1 -17
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { resolveKeybindOption, upsertCommandRegistration } from "./command"
import { upsertCommandRegistration } from "./command"
describe("upsertCommandRegistration", () => {
test("replaces keyed registrations", () => {
@@ -23,19 +23,3 @@ describe("upsertCommandRegistration", () => {
expect(next[1]?.options).toBe(one)
})
})
describe("resolveKeybindOption", () => {
test("prefers a matching contextual command over the global fallback", () => {
const fallback = { id: "tab.close", title: "Close tab" }
const contextual = { id: "terminal.close", title: "Close terminal", when: () => true }
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(contextual)
})
test("uses the global fallback outside the command context", () => {
const fallback = { id: "tab.close", title: "Close tab" }
const contextual = { id: "terminal.close", title: "Close terminal", when: () => false }
expect(resolveKeybindOption([fallback, contextual], new KeyboardEvent("keydown"))).toBe(fallback)
})
})
+5 -16
View File
@@ -82,15 +82,10 @@ export interface CommandOption {
suggested?: boolean
disabled?: boolean
hidden?: boolean
when?: (event: KeyboardEvent) => boolean
onSelect?: (source?: "palette" | "keybind" | "slash") => void
onHighlight?: () => (() => void) | void
}
export function resolveKeybindOption(candidates: CommandOption[] | undefined, event: KeyboardEvent) {
return candidates?.find((option) => option.when?.(event)) ?? candidates?.find((option) => !option.when)
}
type CommandSource = "palette" | "keybind" | "slash"
export type CommandCatalogItem = {
@@ -339,7 +334,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
})
const keymap = createMemo(() => {
const map = new Map<string, CommandOption[]>()
const map = new Map<string, CommandOption>()
for (const option of options()) {
if (option.id.startsWith(SUGGESTED_PREFIX)) continue
if (option.disabled) continue
@@ -349,12 +344,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
for (const kb of keybinds) {
if (!kb.key) continue
const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
const existing = map.get(sig)
if (existing) {
existing.push(option)
continue
}
map.set(sig, [option])
if (map.has(sig)) continue
map.set(sig, option)
}
}
return map
@@ -383,7 +374,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const sig = signatureFromEvent(event)
const isPalette = palette().has(sig)
const option = resolveKeybindOption(keymap().get(sig), event)
const option = keymap().get(sig)
const modified = event.ctrlKey || event.metaKey || event.altKey
const isTab = event.key === "Tab"
@@ -392,19 +383,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (isPalette) {
event.preventDefault()
event.stopPropagation()
showPalette()
return
}
if (!option) return
event.preventDefault()
event.stopPropagation()
option.onSelect?.("keybind")
}
onMount(() => {
makeEventListener(document, "keydown", handleKeyDown, { capture: true })
makeEventListener(document, "keydown", handleKeyDown)
})
function register(cb: () => CommandOption[]): void
@@ -174,7 +174,7 @@ describe("server session", () => {
await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 2, before: undefined }])
expect(ctx.store.data.message.root).toEqual([])
})
@@ -194,7 +194,7 @@ describe("server session", () => {
await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(true)
+1 -1
View File
@@ -21,7 +21,7 @@ import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } fro
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 20
const initialMessagePageSize = 2
const historyMessagePageSize = 200
const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
+2 -35
View File
@@ -28,11 +28,6 @@ export type DraftTab = {
export type Tab = SessionTab | DraftTab
export type TabInfo = {
title?: string
directory?: string
}
type RecentTab = {
key?: string
}
@@ -69,7 +64,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore<Record<string, TabInfo>>({}))
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const params = useParams()
@@ -108,15 +102,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
}
const removeInfo = (key: string) => {
if (!info[key]) return
setInfo(
produce((draft) => {
delete draft[key]
}),
)
}
onCleanup(memory.dispose)
createEffect(() => {
@@ -125,19 +110,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const next = store.filter((tab) => servers.has(tab.server))
if (next.length !== store.length) {
for (const tab of store) {
if (!servers.has(tab.server)) {
const key = tabKey(tab)
memory.remove(key)
removeInfo(key)
}
if (!servers.has(tab.server)) memory.remove(tabKey(tab))
}
setStore(() => next)
}
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
const keys = new Set(next.map(tabKey))
for (const key of Object.keys(info)) {
if (!keys.has(key)) removeInfo(key)
}
})
createEffect(() => {
@@ -173,7 +150,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (nextTab) navigateTab(nextTab)
}).finally(() => closing.delete(key))
memory.remove(key)
removeInfo(key)
if (draftID) removeDraftPersisted(draftID)
}
@@ -288,7 +264,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const removed = store.filter((tab) => tab.server === key).map(tabKey)
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
@@ -343,14 +318,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
})
for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key)
},
rememberSessionInfo(tab: SessionTab, session: Session) {
const key = tabKey(tab)
const next = { title: session.title, directory: session.directory }
const current = info[key]
if (current?.title === next.title && current.directory === next.directory) return
setInfo(key, next)
},
select: navigateTab,
remember(tab: Tab) {
@@ -375,6 +342,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
}
return { ...actions, store, info, ready, recentReady }
return { ...actions, store, ready, recentReady }
},
})
-5
View File
@@ -212,11 +212,6 @@ export const dict = {
"model.input.video": "video",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Allows: {{inputs}}",
"model.tooltip.context.label": "Context",
"model.tooltip.inputs": "Inputs",
"model.tooltip.model": "Model",
"model.tooltip.provider": "Provider",
"model.tooltip.reasoning": "Reasoning",
"model.tooltip.reasoning.allowed": "Allows reasoning",
"model.tooltip.reasoning.none": "No reasoning",
"model.tooltip.context": "Context limit {{limit}}",
+2 -40
View File
@@ -224,26 +224,7 @@
[data-slot="model-selector-scroll"]::before {
top: 0;
height: 12px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--v2-background-bg-layer-01) 80%, transparent) 0%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 78.3336%, transparent) 4.7%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 76.4824%, transparent) 8.9%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 74.368%, transparent) 12.8%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 71.9123%, transparent) 16.56%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 69.037%, transparent) 20.37%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 65.664%, transparent) 24.4%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 61.715%, transparent) 28.83%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 57.1117%, transparent) 33.84%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 51.776%, transparent) 39.6%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 45.6296%, transparent) 46.3%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 38.5944%, transparent) 54.1%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 30.592%, transparent) 63.2%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 21.5443%, transparent) 73.76%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 11.373%, transparent) 85.97%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 0%, transparent) 100%
);
background: linear-gradient(to bottom, var(--v2-background-bg-layer-01), transparent);
}
[data-slot="manage-models-scroll"]::before {
@@ -258,26 +239,7 @@
[data-slot="model-selector-scroll"]::after {
bottom: 0;
height: 12px;
background: linear-gradient(
0deg,
color-mix(in srgb, var(--v2-background-bg-layer-01) 80%, transparent) 0%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 78.3336%, transparent) 4.7%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 76.4824%, transparent) 8.9%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 74.368%, transparent) 12.8%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 71.9123%, transparent) 16.56%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 69.037%, transparent) 20.37%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 65.664%, transparent) 24.4%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 61.715%, transparent) 28.83%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 57.1117%, transparent) 33.84%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 51.776%, transparent) 39.6%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 45.6296%, transparent) 46.3%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 38.5944%, transparent) 54.1%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 30.592%, transparent) 63.2%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 21.5443%, transparent) 73.76%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 11.373%, transparent) 85.97%,
color-mix(in srgb, var(--v2-background-bg-layer-01) 0%, transparent) 100%
);
background: linear-gradient(to top, var(--v2-background-bg-layer-01), transparent);
}
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
+3 -3
View File
@@ -35,7 +35,7 @@ import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useDirectoryPicker } from "@/components/directory-picker"
import { useSettingsDialog } from "@/components/settings-dialog"
import { useSettingsCommand } from "@/components/settings-dialog"
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { ServerConnection, serverName, useServer } from "@/context/server"
@@ -267,7 +267,7 @@ export function NewHome() {
const command = useCommand()
const notification = useNotification()
const marked = useMarked()
const openSettings = useSettingsDialog()
const openSettings = useSettingsCommand()
let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({
search: "",
@@ -1278,7 +1278,7 @@ function HomeSessionSearch(props: {
</div>
</div>
</Show>
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02 focus-within:bg-v2-background-bg-layer-02">
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02">
<IconV2 name="magnifying-glass" />
<input
ref={input}
-2
View File
@@ -6,13 +6,11 @@ import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast"
import { useSettingsCommand } from "@/components/settings-dialog"
export default function NewLayout(props: ParentProps) {
const platform = usePlatform()
const navigate = useNavigate()
setNavigate(navigate)
useSettingsCommand()
createEffect(() => setV2Toast(true))
@@ -2,8 +2,8 @@ import { createMemo, type Accessor } from "solid-js"
import { useGlobal } from "@/context/global"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { sessionPermissionRequest, sessionQuestionRequest } from "@/pages/session/composer/session-request-tree"
import { ServerConnection } from "@/context/server"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
export function useSessionTabAvatarState(
server: Accessor<ServerConnection.Key>,
@@ -26,20 +26,15 @@ export function useSessionTabAvatarState(
return !permission.autoResponds(item, directory())
})
})
const hasQuestions = createMemo(() => {
const serverSync = sync()
if (!serverSync) return false
const [store] = serverSync.child(directory(), { bootstrap: false })
return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId())
const unread = createMemo(() => {
if (hasPermissions()) return true
if (!connection()) return false
return notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0
})
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const unread = createMemo(
() => needsAttention() || notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0,
)
const loading = createMemo(() => {
const serverSync = sync()
if (!serverSync) return false
if (needsAttention()) return false
if (hasPermissions()) return false
return serverSync.session.data.session_working(sessionId())
})
return { unread, loading }
+2
View File
@@ -6,6 +6,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { NewSessionDesignView } from "@/components/session"
import { PromptInput } from "@/components/prompt-input"
import { StatusPopoverV2 } from "@/components/status-popover"
import { useSettingsCommand } from "@/components/settings-dialog"
import {
PromptProjectAddButton,
PromptProjectSelector,
@@ -44,6 +45,7 @@ export default function NewSessionPage() {
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
useComposerCommands()
useSettingsCommand()
let inputRef: HTMLDivElement | undefined
+15 -7
View File
@@ -35,7 +35,7 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { Navigate, useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { ErrorPage } from "@/pages/error"
import { CommentsProvider, useComments } from "@/context/comments"
@@ -56,6 +56,7 @@ import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { useSettingsCommand } from "@/components/settings-dialog"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import {
createPromptInputController,
@@ -152,7 +153,7 @@ export function TargetSessionRouteContent() {
)
}
export function SessionRouteErrorBoundary(
function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
) {
const settings = useSettings()
@@ -220,6 +221,7 @@ function SessionErrorFallback(props: { error: unknown; sessionID?: string; serve
function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
const settings = useSettings()
const tabs = useTabs()
const sync = useServerSync()
const serverKey = createMemo(() => requireServerKey(params.serverKey))
@@ -246,11 +248,16 @@ function ResolvedTargetSessionRoute() {
the terminal. Same-workspace tab switches keep it open because warm
targets resolve synchronously from the sync cache. */}
<Show when={directory()}>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</TargetServerScopedProviders>
)
@@ -1014,6 +1021,7 @@ export default function Page() {
}
useComposerCommands()
useSettingsCommand()
useSessionCommands({
navigateMessageByOffset,
setActiveMessage,
@@ -18,6 +18,8 @@ import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useTabs } from "@/context/tabs"
import { requireServerKey } from "@/utils/session-route"
import { createSessionOwnership } from "./session-ownership"
export type SessionCommandContext = {
@@ -46,6 +48,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
const sessionTabs = useTabs()
const layout = useLayout()
const navigate = useNavigate()
const { params, sessionKey, tabs, view } = useSessionLayout()
@@ -266,14 +269,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
view().terminal.open()
}
const closeTerminal = () => {
const id = terminal.active()
if (!id) return
const last = terminal.all().length === 1
void terminal.close(id)
if (last) view().terminal.close()
}
const chooseMcp = () => {
void openDialog(
() => import("@/components/dialog-select-mcp"),
@@ -416,9 +411,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: (source) => {
if (settings.general.newLayoutDesigns()) {
command.trigger("tab.new", source)
onSelect: () => {
if (params.serverKey) {
sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
return
}
navigate(`/${params.dir}/session`)
@@ -523,14 +518,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
]
const terminalCmds = () => [
terminalCommand({
id: "terminal.close",
title: language.t("terminal.close"),
keybind: "mod+w",
hidden: true,
when: (event) => event.target instanceof Element && !!event.target.closest('[data-component="terminal"]'),
onSelect: closeTerminal,
}),
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.17.15",
"version": "1.17.14",
"type": "module",
"license": "MIT",
"bin": {
@@ -21,6 +21,14 @@ import { Api } from "@opencode-ai/server/api"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
const Client = await import("../src/effect")
test("effect entrypoint exposes canonical Schema contracts", () => {
expect(Client.Agent).toBe(Agent)
expect(Client.Model).toBe(Model)
expect(Client.Session).toBe(Session)
})
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
expect(AgentV2.ID).toBe(Agent.ID)
expect(CoreLocation.Ref).toBe(Location.Ref)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.17.15",
"version": "1.17.14",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.17.15",
"version": "1.17.14",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.17.15",
"version": "1.17.14",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.17.15",
"version": "1.17.14",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.17.15",
"version": "1.17.14",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.17.15",
"version": "1.17.14",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.15",
"version": "1.17.14",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+4 -41
View File
@@ -11,11 +11,6 @@ import { FSUtil } from "../../fs-util"
import { ModelV2 } from "../../model"
import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate"
import { Global } from "../../global"
import { PermissionV2 } from "../../permission"
import type { LocationMutation } from "../../location-mutation"
import type { ReadTool } from "../../tool/read"
import type { EditTool } from "../../tool/edit"
const legacySources = [
{ pattern: "{agent,agents}/**/*.md", primary: false },
@@ -24,11 +19,6 @@ const legacySources = [
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
type PathAction =
| LocationMutation.ExternalDirectoryAuthorization["action"]
| typeof ReadTool.name
| typeof EditTool.name
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
const agentKeys = new Set([
"model",
"variant",
@@ -48,7 +38,6 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
yield* ctx.agent.transform(
Effect.fn(function* (draft) {
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
@@ -67,14 +56,11 @@ export const Plugin = define({
)
})
}).pipe(Effect.map((documents) => documents.flat()))
const permissions = expandPermissions(
documents.flatMap((document) => document.info.permissions ?? []),
global.home,
)
const global = documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(documents, "default_agent")
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
draft.update(current.id, (agent) => agent.permissions.push(...global))
}
for (const document of documents) {
@@ -87,7 +73,7 @@ export const Plugin = define({
const exists = draft.get(agentID) !== undefined
draft.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...permissions)
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
@@ -105,9 +91,7 @@ export const Plugin = define({
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) {
agent.permissions.push(...expandPermissions(item.permissions, global.home))
}
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
})
}
}
@@ -116,27 +100,6 @@ export const Plugin = define({
}),
})
function expandPermissions(rules: PermissionV2.Ruleset, home: string): PermissionV2.Ruleset {
// Expand only resources tools resolve as filesystem paths. Bash resources are raw shell text:
// rewriting `$HOME/private/**` would miss `$HOME/private/key`, and safe expansion needs shell-aware parsing.
return rules.map((rule) =>
isPathAction(rule.action) ? { ...rule, resource: expandHome(rule.resource, home) } : rule,
)
}
function isPathAction(action: string): action is PathAction {
return pathActions.some((item) => item === action)
}
function expandHome(resource: string, home: string) {
if (resource.startsWith("~/")) return home + resource.slice(1)
if (resource === "~") return home
if (resource === "$HOME") return home
if (resource.startsWith("$HOME/")) return home + resource.slice(5)
if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
return resource
}
function discover(fs: FSUtil.Interface, directory: string) {
return Effect.forEach(legacySources, (source) =>
fs
+3 -4
View File
@@ -60,10 +60,9 @@ export namespace FSUtil {
})
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
return yield* fs.readFileString(path).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
Effect.catchReason("PlatformError", "PermissionDenied", () => Effect.succeed(undefined)),
)
return yield* fs
.readFileString(path)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
})
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
+4 -11
View File
@@ -22,27 +22,20 @@ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <te
- [constraints/preferences, decisions and why, important facts/assumptions, exact context needed to continue, or "(none)"]
## Work State
### Completed
- [finished work, verified facts, or changes made; otherwise "(none)"]
### Active
- [current work, partial changes, or investigation state; otherwise "(none)"]
### Blocked
- [blockers, failing commands, or unknowns; otherwise "(none)"]
- Completed: [finished work, verified facts, or changes made; otherwise "(none)"]
- Active: [current work, partial changes, or investigation state; otherwise "(none)"]
- Blocked: [blockers, failing commands, or unknowns; otherwise "(none)"]
## Next Move
1. [immediate concrete action, or "(none)"]
2. [next action if known, or "(none)"]
## Relevant Files
- [file or directory path: why it matters, or "(none)"]
</template>
Rules:
- Keep every section, even when empty.
- Use terse bullets, not prose paragraphs.
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
- Put relevant files and symbols inside the section where they matter; do not add extra sections.
- Do not mention the summary process or that context was compacted.`
type Entry = {
+6 -6
View File
@@ -7,20 +7,20 @@ import { makeGlobalNode } from "../effect/app-node"
import { SessionHistory } from "./history"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessageTable, SessionTable } from "./sql"
import { fromRow } from "./info"
export interface Interface {
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
readonly runnerContext: (
sessionID: SessionSchema.ID,
sessionID: Session.ID,
baselineSeq: number,
) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
readonly message: (
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Message } | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionStore") {}
@@ -51,7 +51,7 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
return row
? {
sessionID: SessionSchema.ID.make(row.session_id),
sessionID: Session.ID.make(row.session_id),
message: yield* decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie),
}
: undefined
+1 -76
View File
@@ -8,43 +8,16 @@ import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node, Global.node])))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node])))
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigAgentPlugin.Plugin", () => {
it.effect("matches POSIX paths against home-relative permissions", () =>
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("/home/test")
expect(PermissionV2.evaluate("external_directory", "/home/test/p/opencode/src/*", permissions).effect).toBe(
"allow",
)
expect(PermissionV2.evaluate("external_directory", "/home/test/cache/files/*", permissions).effect).toBe("deny")
expect(PermissionV2.evaluate("external_directory", "/some/~/path", permissions).effect).toBe("deny")
expect(PermissionV2.evaluate("external_directory", "$HOMELESS/private/*", permissions).effect).toBe("deny")
expect(PermissionV2.evaluate("bash", "$HOME/private/key", permissions).effect).toBe("deny")
}),
)
it.effect("matches Windows paths against home-relative permissions", () =>
Effect.gen(function* () {
const permissions = yield* loadHomePermissions("C:\\Users\\test")
expect(
PermissionV2.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
).toBe("allow")
expect(PermissionV2.evaluate("external_directory", "C:\\Users\\test\\cache\\files\\*", permissions).effect).toBe(
"deny",
)
}),
)
it.effect("applies all global permissions before agent-specific permissions", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
@@ -299,51 +272,3 @@ Use native v2 fields.`,
),
)
})
function loadHomePermissions(home: string) {
return Effect.gen(function* () {
const agents = yield* AgentV2.Service
const build = AgentV2.ID.make("build")
yield* agents.transform((editor) => editor.update(build, () => {}))
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
permission: {
external_directory: {
"~/p/**": "allow",
"/some/~/path": "deny",
"$HOMELESS/**": "deny",
},
bash: {
"$HOME/private/**": "deny",
},
},
agent: {
build: {
permission: {
external_directory: {
"$HOME/cache/**": "deny",
},
},
},
},
}),
),
}),
]),
})
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
)
const agent = yield* agents.get(build)
if (!agent) throw new Error("expected configured build agent")
return agent.permissions
})
}
@@ -1,15 +1,6 @@
import { expect, test } from "bun:test"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
test("compaction prompt preserves detailed work state and relevant files", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
expect(prompt).toContain("## Work State\n### Completed")
expect(prompt).toContain("### Active")
expect(prompt).toContain("### Blocked")
expect(prompt).toContain("## Relevant Files")
})
test("compaction describes tool media without embedding base64", () => {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const serialized = SessionCompaction.serializeToolContent([
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.17.15",
"version": "1.17.14",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+2 -12
View File
@@ -71,10 +71,7 @@ export function setAppQuitting(quitting = true) {
export function setBackgroundColor(color: string) {
backgroundColor = color
BrowserWindow.getAllWindows().forEach((win) => {
win.setBackgroundColor(color)
if (process.platform === "darwin") win.invalidateShadow()
})
BrowserWindow.getAllWindows().forEach((win) => win.setBackgroundColor(color))
}
export function getBackgroundColor(): string | undefined {
@@ -109,13 +106,6 @@ function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
titlebarThemes.set(win, theme)
// macOS draws the window frame hairline and shadow using the NSWindow
// appearance, which follows nativeTheme rather than the rendered content.
// Align it with the app theme so a light app on a dark system does not get
// the dark-appearance border and shadow. A "system" scheme must map to
// "system" (not the resolved mode) or prefers-color-scheme stops tracking
// OS appearance changes in the renderer.
if (process.platform === "darwin") nativeTheme.themeSource = theme.scheme ?? theme.mode ?? "system"
updateTitlebar(win)
}
@@ -182,7 +172,7 @@ export function createMainWindow(id: string = randomUUID()) {
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
trafficLightPosition: { x: 14, y: 14 },
trafficLightPosition: { x: 12, y: 14 },
}
: {}),
...(process.platform === "win32"
-1
View File
@@ -31,7 +31,6 @@ export type UpdaterAPI = {
export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
mode: "light" | "dark"
scheme?: "system" | "light" | "dark"
}
export type FatalRendererError = {
error: string
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.15",
"version": "1.17.14",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.15",
"version": "1.17.14",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.17.15",
"version": "1.17.14",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.17.15",
"version": "1.17.14",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.15",
"version": "1.17.14",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.15",
"version": "1.17.14",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
-1
View File
@@ -6,7 +6,6 @@ const patterns = [
/input is too long for requested model/i,
/exceeds the context window/i,
/input token count.*exceeds the maximum/i,
/tokens in request more than max tokens allowed/i,
/maximum prompt length is \d+/i,
/reduce the length of the messages/i,
/maximum context length is \d+ tokens/i,
-8
View File
@@ -1,8 +0,0 @@
import { describe, expect, test } from "bun:test"
import { isContextOverflow } from "../src"
describe("provider error classification", () => {
test("classifies Z.AI GLM token limit messages as context overflow", () => {
expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.15",
"version": "1.17.14",
"name": "opencode",
"type": "module",
"license": "MIT",
-1
View File
@@ -293,7 +293,6 @@ const layer = Layer.effect(
})
const ensureGitignore = Effect.fn("Config.ensureGitignore")(function* (dir: string) {
yield* fs.ensureDir(dir)
const gitignore = path.join(dir, ".gitignore")
const hasIgnore = yield* fs.existsSafe(gitignore)
if (!hasIgnore) {
@@ -923,31 +923,6 @@ it.effect("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
)
it.effect("ignores an inaccessible OPENCODE_CONFIG_DIR", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const dir = yield* tmpdirScoped()
const configDir = path.join(dir, "inaccessible")
yield* FSUtil.use.ensureDir(configDir)
yield* FSUtil.use.chmod(configDir, 0o000)
yield* Effect.addFinalizer(() => FSUtil.use.chmod(configDir, 0o755).pipe(Effect.ignore))
yield* withProcessEnv("OPENCODE_CONFIG_DIR", configDir, Config.use.get().pipe(provideInstanceEffect(dir)))
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
)
it.effect("creates a missing OPENCODE_CONFIG_DIR", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const configDir = path.join(dir, "configdir")
yield* withProcessEnv("OPENCODE_CONFIG_DIR", configDir, Config.use.get().pipe(provideInstanceEffect(dir)))
expect(yield* FSUtil.use.readFileString(path.join(configDir, ".gitignore"))).toContain("node_modules")
}).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
)
it.effect("installs dependencies in writable OPENCODE_CONFIG_DIR", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
@@ -1448,7 +1448,6 @@ describe("session.message-v2.fromError", () => {
"prompt is too long: 213462 tokens > 200000 maximum",
"Your input exceeds the context window of this model",
"The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)",
"tokens in request more than max tokens allowed",
"Please reduce the length of the messages or completion",
"400 status code (no body)",
"413 status code (no body)",
+3 -1
View File
@@ -1,10 +1,11 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.17.15",
"version": "1.17.14",
"type": "module",
"license": "MIT",
"scripts": {
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit",
"build": "tsc"
},
@@ -22,6 +23,7 @@
],
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:"
+5 -4
View File
@@ -1,11 +1,12 @@
import type { AgentV2Info } from "@opencode-ai/sdk/v2/types"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Types } from "effect"
import type { Hooks } from "./registration.js"
export interface AgentDraft {
list(): readonly AgentV2Info[]
get(id: string): AgentV2Info | undefined
list(): readonly Types.DeepMutable<Agent.Info>[]
get(id: string): Types.DeepMutable<Agent.Info> | undefined
default(id: string | undefined): void
update(id: string, update: (agent: AgentV2Info) => void): void
update(id: string, update: (agent: Types.DeepMutable<Agent.Info>) => void): void
remove(id: string): void
}
+3 -3
View File
@@ -1,16 +1,16 @@
import type { LanguageModelV3 } from "@ai-sdk/provider"
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import type { Model } from "@opencode-ai/schema/model"
import type { Hooks } from "./registration.js"
export type AISDKHooks = Hooks<{
sdk: {
readonly model: ModelV2Info
readonly model: Model.Info
readonly package: string
readonly options: Record<string, any>
sdk?: any
}
language: {
readonly model: ModelV2Info
readonly model: Model.Info
readonly sdk: any
readonly options: Record<string, any>
language?: LanguageModelV3
+10
View File
@@ -1,3 +1,13 @@
export type { PluginContext } from "./context.js"
export { define } from "./plugin.js"
export type { Plugin } from "./plugin.js"
export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Connection } from "@opencode-ai/schema/connection"
export { Credential } from "@opencode-ai/schema/credential"
export { Integration } from "@opencode-ai/schema/integration"
export { Model } from "@opencode-ai/schema/model"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
+3 -3
View File
@@ -1,16 +1,16 @@
import type { LanguageModelV3 } from "@ai-sdk/provider"
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import type { Model } from "@opencode-ai/schema/model"
import type { Hooks } from "./registration.js"
export type AISDKHooks = Hooks<{
sdk: {
readonly model: ModelV2Info
readonly model: Model.Info
readonly package: string
readonly options: Record<string, any>
sdk?: any
}
language: {
readonly model: ModelV2Info
readonly model: Model.Info
readonly sdk: any
readonly options: Record<string, any>
language?: LanguageModelV3
+10
View File
@@ -10,3 +10,13 @@ export type { CommandDraft, CommandHooks } from "./command.js"
export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js"
export type { ReferenceDraft, ReferenceHooks } from "./reference.js"
export type { SkillDraft, SkillHooks } from "./skill.js"
export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Connection } from "@opencode-ai/schema/connection"
export { Credential } from "@opencode-ai/schema/credential"
export { Integration } from "@opencode-ai/schema/integration"
export { Model } from "@opencode-ai/schema/model"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
@@ -0,0 +1,40 @@
import { expect, test } from "bun:test"
import { Agent } from "@opencode-ai/schema/agent"
import { Command } from "@opencode-ai/schema/command"
import { Connection } from "@opencode-ai/schema/connection"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Reference } from "@opencode-ai/schema/reference"
import { Skill } from "@opencode-ai/schema/skill"
const Plugin = await import("../src/v2/effect/index")
const PromisePlugin = await import("../src/v2/promise/index")
test.each([
["effect", Plugin],
["promise", PromisePlugin],
])("%s entrypoint exposes its canonical Schema contracts", (_, entrypoint) => {
expect(entrypoint.Agent).toBe(Agent)
expect(entrypoint.Command).toBe(Command)
expect(entrypoint.Connection).toBe(Connection)
expect(entrypoint.Credential).toBe(Credential)
expect(entrypoint.Integration).toBe(Integration)
expect(entrypoint.Model).toBe(Model)
expect(entrypoint.Provider).toBe(Provider)
expect(entrypoint.Reference).toBe(Reference)
expect(entrypoint.Skill).toBe(Skill)
expect(Object.keys(entrypoint).sort()).toEqual([
"Agent",
"Command",
"Connection",
"Credential",
"Integration",
"Model",
"Provider",
"Reference",
"Skill",
"define",
])
})
+2 -2
View File
@@ -4,9 +4,9 @@
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"module": "nodenext",
"module": "esnext",
"declaration": true,
"moduleResolution": "nodenext",
"moduleResolution": "bundler",
"lib": ["es2022", "dom", "dom.iterable"]
},
"include": ["src"]
+7
View File
@@ -34,6 +34,13 @@
- Use flat top-level exports plus the package's existing namespace projection pattern, for example `export * as SessionMessage from "./session-message"`.
- Keep standalone ID modules only when they prevent real cycles or heavy dependency edges. Inline one-off IDs into their owning contract module when no cycle exists.
## Public Re-exports
- Public leaf packages re-export the canonical domain namespaces used by their APIs so consumers install and version one package. Keep the list curated by public API reachability rather than mirroring the Schema root.
- Re-export directly from `@opencode-ai/schema/<domain>` and preserve the exact canonical values and types. Do not wrap schemas, duplicate definitions, or route exports through another facade package.
- Internal packages import contracts directly from their canonical Schema owner. Import a Core domain facade only when runtime behavior from that facade is required.
- Resolve name collisions by retaining the domain namespace and qualifying its members, such as `Session.Info` and `Agent.Info`. Do not introduce aliases such as `SessionSchema`, `AgentSchema`, or `ModelSchema`.
## Naming
- Exported schema values and namespace objects use `PascalCase`.
+1
View File
@@ -14,6 +14,7 @@
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"effect": "catalog:"
},
+21 -12
View File
@@ -2,16 +2,25 @@ export * as OpenCode from "./opencode"
export * as Tool from "./tool"
export { ClientError } from "@opencode-ai/client/effect"
export {
AbsolutePath,
Agent,
Location,
Model,
Prompt,
Provider,
RelativePath,
Session,
SessionInput,
SessionMessage,
} from "@opencode-ai/client/effect"
export type { OpenCodeEvent } from "@opencode-ai/client/effect"
export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Credential } from "@opencode-ai/schema/credential"
export { FileSystem } from "@opencode-ai/schema/filesystem"
export { Integration } from "@opencode-ai/schema/integration"
export { Location } from "@opencode-ai/schema/location"
export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
export { Prompt } from "@opencode-ai/schema/prompt"
export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
export { Reference } from "@opencode-ai/schema/reference"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session"
export { SessionInput } from "@opencode-ai/schema/session-input"
export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill"
@@ -0,0 +1,39 @@
import { expect, test } from "bun:test"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { Session } from "@opencode-ai/schema/session"
const SDK = await import("../src/index")
test("re-exports canonical contracts directly from Schema", () => {
expect(SDK.Agent).toBe(Agent)
expect(SDK.Model).toBe(Model)
expect(SDK.Session).toBe(Session)
expect(Object.keys(SDK).sort()).toEqual([
"AbsolutePath",
"Agent",
"ClientError",
"Command",
"Credential",
"FileSystem",
"Integration",
"Location",
"Model",
"OpenCode",
"Permission",
"PermissionSaved",
"Project",
"ProjectCopy",
"Prompt",
"Provider",
"Pty",
"Question",
"Reference",
"RelativePath",
"Session",
"SessionInput",
"SessionMessage",
"Skill",
"Tool",
])
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.17.15",
"version": "1.17.14",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.17.15",
"version": "1.17.14",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.17.15",
"version": "1.17.14",
"private": true,
"type": "module",
"license": "MIT",
@@ -121,20 +121,6 @@
margin: 0;
}
li:has(> input[type="checkbox"]),
li:has(> p > input[type="checkbox"]) {
position: relative;
list-style-type: none;
}
li > input[type="checkbox"],
li > p > input[type="checkbox"] {
position: absolute;
top: 4px;
left: -20px;
margin: 0;
}
li > p + p {
display: block;
margin-top: 0.5rem;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.17.15",
"version": "1.17.14",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.17.15",
"version": "1.17.14",
"private": true,
"type": "module",
"license": "MIT",
+3 -8
View File
@@ -210,30 +210,25 @@ const en = {
"model.totalModels": "{{count}} models",
"model.momentum": "Momentum",
"model.vsPreviousWindow": "vs previous window",
"model.usageDescription": "Daily token volume.",
"model.usageDescription": "Daily OpenCode Go token volume over the recent two-month window.",
"model.noUsageTitle": "No usage",
"model.noUsageDescription": "No usage landed in the current window.",
"model.dailyTokenChart": "Daily token usage chart",
"model.usersDescription": "Daily unique users.",
"model.usersDescription": "Daily unique OpenCode Go users over the recent two-month window.",
"model.noUsersTitle": "No user data",
"model.noUsersDescription": "No user-bearing rows landed in the current window.",
"model.dailyUserChart": "Daily unique user chart",
"model.efficiencyDescription": "Cost, cache behavior, and average session shape.",
"model.efficiencyDescription": "Cost, cache behavior, and average OpenCode Go session shape.",
"model.noEfficiencyTitle": "No efficiency data",
"model.noEfficiencyDescription": "Efficiency data appears after usage lands.",
"model.totalSpendLabel": "Total spend",
"model.cost": "Cost",
"model.totalSpend": "total spend",
"model.costPerMillion": "Cost / 1M",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.inputOutput": "input / output",
"model.observedTokens": "observed all tokens",
"model.costSession": "Cost / Session",
"model.averageCostSession": "Average cost / Session",
"model.average": "average",
"model.tokensSession": "Tokens / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cacheRatio": "Cache Ratio",
"model.inputTokens": "input tokens",
"model.geoDescription": "OpenCode Go model tokens used by country.",
-5
View File
@@ -202,11 +202,6 @@ export const dict = {
"model.efficiencyDescription": "التكلفة، وسلوك التخزين المؤقت، ومتوسط شكل جلسة OpenCode Go.",
"model.noEfficiencyTitle": "لا توجد بيانات كفاءة",
"model.noEfficiencyDescription": "تظهر بيانات الكفاءة بعد وصول الاستخدام.",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "التكلفة",
"model.totalSpend": "إجمالي الإنفاق",
"model.costPerMillion": "التكلفة / 1M",
-5
View File
@@ -204,11 +204,6 @@ export const dict = {
"model.efficiencyDescription": "Custo, comportamento de cache e formato médio da sessão do OpenCode Go.",
"model.noEfficiencyTitle": "Sem dados de eficiência",
"model.noEfficiencyDescription": "Os dados de eficiência aparecem depois que o uso chega.",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "Custo",
"model.totalSpend": "gasto total",
"model.costPerMillion": "Custo / 1M",
-5
View File
@@ -203,11 +203,6 @@ export const dict = {
"model.efficiencyDescription": "Omkostning, cacheadfærd og gennemsnitlig OpenCode Go-sessionsform.",
"model.noEfficiencyTitle": "Ingen effektivitetsdata",
"model.noEfficiencyDescription": "Effektivitetsdata vises, når brug lander.",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "Omkostning",
"model.totalSpend": "samlet forbrug",
"model.costPerMillion": "Omkostning / 1 mio.",
-5
View File
@@ -204,11 +204,6 @@ export const dict = {
"model.efficiencyDescription": "Kosten, Cache-Verhalten und durchschnittliche OpenCode Go-Sitzungsform.",
"model.noEfficiencyTitle": "Keine Effizienzdaten",
"model.noEfficiencyDescription": "Effizienzdaten erscheinen, nachdem Nutzung eingegangen ist.",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "Kosten",
"model.totalSpend": "Gesamtausgaben",
"model.costPerMillion": "Kosten / 1 Mio.",
-5
View File
@@ -203,11 +203,6 @@ export const dict = {
"model.efficiencyDescription": "Coste, comportamiento de caché y forma media de sesión de OpenCode Go.",
"model.noEfficiencyTitle": "Sin datos de eficiencia",
"model.noEfficiencyDescription": "Los datos de eficiencia aparecen después de que llegue uso.",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "Coste",
"model.totalSpend": "gasto total",
"model.costPerMillion": "Coste / 1 M",
-5
View File
@@ -205,11 +205,6 @@ export const dict = {
"model.efficiencyDescription": "Coût, comportement du cache et forme moyenne des sessions OpenCode Go.",
"model.noEfficiencyTitle": "Aucune donnée d'efficacité",
"model.noEfficiencyDescription": "Les données d'efficacité apparaissent après l'arrivée de l'utilisation.",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "Coût",
"model.totalSpend": "dépense totale",
"model.costPerMillion": "Coût / 1 M",
-5
View File
@@ -204,11 +204,6 @@ export const dict = {
"model.efficiencyDescription": "Costi, comportamento della cache e forma media della sessione OpenCode Go.",
"model.noEfficiencyTitle": "Nessun dato di efficienza",
"model.noEfficiencyDescription": "I dati di efficienza appaiono dopo l'arrivo dell'utilizzo.",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "Costo",
"model.totalSpend": "spesa totale",
"model.costPerMillion": "Costo / 1 M",
-5
View File
@@ -205,11 +205,6 @@ export const dict = {
"model.efficiencyDescription": "コスト、キャッシュ動作、平均的なOpenCode Goセッションの形。",
"model.noEfficiencyTitle": "効率データがありません",
"model.noEfficiencyDescription": "使用量が届くと効率データが表示されます。",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "コスト",
"model.totalSpend": "総支出",
"model.costPerMillion": "100万あたりのコスト",
-5
View File
@@ -205,11 +205,6 @@ export const dict = {
"model.efficiencyDescription": "비용, 캐시 동작, 평균 OpenCode Go 세션 형태입니다.",
"model.noEfficiencyTitle": "효율 데이터 없음",
"model.noEfficiencyDescription": "사용량이 들어오면 효율 데이터가 표시됩니다.",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "비용",
"model.totalSpend": "총 지출",
"model.costPerMillion": "100만당 비용",
-5
View File
@@ -203,11 +203,6 @@ export const dict = {
"model.efficiencyDescription": "Kostnad, cacheatferd og gjennomsnittlig OpenCode Go-øktform.",
"model.noEfficiencyTitle": "Ingen effektivitetsdata",
"model.noEfficiencyDescription": "Effektivitetsdata vises etter at bruk lander.",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "Kostnad",
"model.totalSpend": "totalt forbruk",
"model.costPerMillion": "Kostnad / 1 mill.",
-5
View File
@@ -202,11 +202,6 @@ export const dict = {
"model.efficiencyDescription": "Koszt, zachowanie cache i średni kształt sesji OpenCode Go.",
"model.noEfficiencyTitle": "Brak danych wydajności",
"model.noEfficiencyDescription": "Dane wydajności pojawią się po nadejściu użycia.",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "Koszt",
"model.totalSpend": "łączne wydatki",
"model.costPerMillion": "Koszt / 1 mln",
-5
View File
@@ -204,11 +204,6 @@ export const dict = {
"model.efficiencyDescription": "Стоимость, поведение кэша и средняя форма сеанса OpenCode Go.",
"model.noEfficiencyTitle": "Нет данных эффективности",
"model.noEfficiencyDescription": "Данные эффективности появятся после использования.",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "Стоимость",
"model.totalSpend": "общие расходы",
"model.costPerMillion": "Стоимость / 1 млн",
-5
View File
@@ -204,11 +204,6 @@ export const dict = {
"model.efficiencyDescription": "ต้นทุน พฤติกรรมแคช และรูปแบบเซสชัน OpenCode Go โดยเฉลี่ย",
"model.noEfficiencyTitle": "ไม่มีข้อมูลประสิทธิภาพ",
"model.noEfficiencyDescription": "ข้อมูลประสิทธิภาพจะแสดงหลังจากมีการใช้งานเข้ามา",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "ต้นทุน",
"model.totalSpend": "ยอดใช้จ่ายรวม",
"model.costPerMillion": "ต้นทุน / 1 ล้าน",
-5
View File
@@ -204,11 +204,6 @@ export const dict = {
"model.efficiencyDescription": "Maliyet, önbellek davranışı ve ortalama OpenCode Go oturum yapısı.",
"model.noEfficiencyTitle": "Verimlilik verisi yok",
"model.noEfficiencyDescription": "Verimlilik verileri kullanım geldikten sonra görünür.",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "Maliyet",
"model.totalSpend": "toplam harcama",
"model.costPerMillion": "Maliyet / 1M",
-5
View File
@@ -204,11 +204,6 @@ export const dict = {
"model.efficiencyDescription": "Вартість, поведінка кешу та середня форма сеансу OpenCode Go.",
"model.noEfficiencyTitle": "Немає даних ефективності",
"model.noEfficiencyDescription": "Дані ефективності з'являться після використання.",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "Вартість",
"model.totalSpend": "загальні витрати",
"model.costPerMillion": "Вартість / 1 млн",
-5
View File
@@ -203,11 +203,6 @@ export const dict = {
"model.efficiencyDescription": "成本、缓存行为和平均 OpenCode Go 会话形态。",
"model.noEfficiencyTitle": "无效率数据",
"model.noEfficiencyDescription": "使用量到达后会显示效率数据。",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "成本",
"model.totalSpend": "总支出",
"model.costPerMillion": "每 100 万成本",
-5
View File
@@ -203,11 +203,6 @@ export const dict = {
"model.efficiencyDescription": "成本、快取行為和平均 OpenCode Go 工作階段形態。",
"model.noEfficiencyTitle": "無效率數據",
"model.noEfficiencyDescription": "使用量到達後會顯示效率數據。",
"model.totalSpendLabel": "Total spend",
"model.costInput": "Cost input",
"model.costOutput": "Cost output",
"model.averageCostSession": "Average cost / Session",
"model.averageTokensSession": "Average tokens / session",
"model.cost": "成本",
"model.totalSpend": "總支出",
"model.costPerMillion": "每 100 萬成本",
+39 -438
View File
@@ -15,7 +15,7 @@ import {
type UsageRange,
} from "@opencode-ai/stats-core/domain/home"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, createSignal, createUniqueId, For, onMount, Show, type JSX } from "solid-js"
import { createMemo, createSignal, For, onMount, Show, type JSX } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson"
import type { GeometryCollection, Topology } from "topojson-specification"
@@ -28,6 +28,7 @@ import {
formatCatalogLabName,
getModelCatalog,
type ModelCatalog,
type ModelCatalogCost,
type ModelCatalogEntry,
} from "../model-catalog"
import { SectionHeading } from "../section-heading"
@@ -120,8 +121,6 @@ export default function StatsModel() {
const modelHeaderLinks = createMemo<readonly HeaderLink[]>(() => [
{ href: "#overview", label: i18n.t("nav.overview") },
{ href: "#momentum", label: i18n.t("model.momentum") },
{ href: "#usage", label: i18n.t("nav.usage") },
{ href: "#unique-users", label: i18n.t("model.uniqueUsers") },
{ href: "#efficiency", label: i18n.t("nav.efficiency") },
{ href: "#geo-breakdown", label: i18n.t("nav.geoBreakdown") },
{ href: "#peers", label: i18n.t("nav.peers") },
@@ -184,8 +183,6 @@ export default function StatsModel() {
/>
<ModelOverview catalog={catalogEntry() ?? null} />
<ModelMomentumSection data={stats() ?? null} />
<ModelUsageSection data={stats() ?? null} />
<ModelUniqueUsersSection data={stats() ?? null} />
<ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} />
<ModelGeoBreakdownSection data={stats()?.country ?? emptyCountryRecord()} />
<ModelPeersSection data={stats() ?? null} />
@@ -564,303 +561,6 @@ function MomentumMetric(props: { label: string; value: string; watermark?: strin
)
}
function ModelUsageSection(props: { data: StatsModelData | null }) {
const i18n = useI18n()
return (
<ModelTrendSection
data={props.data}
id="usage"
title={i18n.t("nav.usage")}
description={i18n.t("model.usageDescription")}
ariaLabel={i18n.t("model.dailyTokenChart")}
emptyTitle={i18n.t("model.noUsageTitle")}
emptyDescription={i18n.t("model.noUsageDescription")}
value={(point) => point.tokens}
formatValue={formatTokens}
valueUnit={i18n.t("lab.tokens")}
rowLabel={i18n.t("lab.dailyTokens")}
lineTone="muted"
/>
)
}
function ModelUniqueUsersSection(props: { data: StatsModelData | null }) {
const i18n = useI18n()
return (
<ModelTrendSection
data={props.data}
id="unique-users"
title={i18n.t("model.uniqueUsers")}
description={i18n.t("model.usersDescription")}
ariaLabel={i18n.t("model.dailyUserChart")}
emptyTitle={i18n.t("model.noUsersTitle")}
emptyDescription={i18n.t("model.noUsersDescription")}
value={(point) => point.users}
formatValue={formatUsers}
valueUnit={i18n.t("format.users")}
rowLabel={i18n.t("model.uniqueUsers")}
lineTone="active"
activeLineBaseTone="muted"
highlightBars={false}
area
/>
)
}
function ModelTrendSection(props: {
data: StatsModelData | null
id: string
title: string
description: string
ariaLabel: string
emptyTitle: string
emptyDescription: string
value: (point: ModelUsagePoint) => number
formatValue: (value: number) => string
valueUnit: string
rowLabel: string
lineTone: "muted" | "active"
activeLineBaseTone?: "muted" | "active"
highlightBars?: boolean
area?: boolean
}) {
const activeLineClipId = createUniqueId()
const activeLineMaskId = createUniqueId()
const areaGradientId = createUniqueId()
const [activeIndex, setActiveIndex] = createSignal<number>()
const highlightBars = createMemo(() => props.highlightBars ?? true)
const lineScale = 326 / 450
const areaBottom = 100 / lineScale
const usage = createMemo(() => props.data?.usage ?? [])
const valueMax = createMemo(() => Math.max(0, ...usage().map((item) => props.value(item))) || 1)
const linePoints = createMemo(() =>
usage().map((point, index) => ({
point,
x: modelUsagePointX(index, usage().length),
y: modelUsageLineY(props.value(point), valueMax()),
})),
)
const linePath = createMemo(() => modelUsageLinePath(linePoints()))
const areaPath = createMemo(() => modelUsageAreaPath(linePoints(), areaBottom))
const activeLineBreak = createMemo(() => {
const index = activeIndex()
if (index === undefined || linePoints().length < 2) return undefined
return modelUsageColumnBounds(index, linePoints().length)
})
const activeLineClip = createMemo(() => {
const index = activeIndex()
if (index === undefined || linePoints().length < 2) return undefined
return modelUsageColumnInnerBounds(index, linePoints().length)
})
const activeTooltip = createMemo(() => {
const index = activeIndex()
const point = index === undefined ? undefined : usage()[index]
if (index === undefined || !point) return undefined
const bounds = modelUsageColumnBounds(index, usage().length)
return {
bounds,
index,
point,
tooltipY: (linePoints()[index]?.y ?? 100) * lineScale,
}
})
const monthTicks = createMemo(() => modelUsageMonthTicks(usage(), props.data?.updatedAt ?? null))
return (
<section id={props.id} data-section="model-panel">
<SectionTitle href={`#${props.id}`} title={props.title} description={props.description} />
<Show
when={usage().some((item) => props.value(item) > 0)}
fallback={<ModelEmptyState title={props.emptyTitle} description={props.emptyDescription} />}
>
<div
data-component="model-usage-chart"
data-variant="model-trend"
data-highlight-bars={highlightBars() ? "true" : undefined}
role="img"
aria-label={props.ariaLabel}
style={{ "--model-usage-count": usage().length } as JSX.CSSProperties}
onPointerLeave={(event) => {
if (event.pointerType === "touch") return
setActiveIndex(undefined)
}}
>
<div data-slot="model-trend-plot">
<Show when={linePath()}>
{(path) => (
<>
<Show when={props.area && areaPath()}>
{(area) => (
<svg
data-slot="model-trend-area-layer"
viewBox={`0 0 100 ${formatUsagePathNumber(areaBottom)}`}
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<linearGradient id={areaGradientId} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="var(--model-trend-active)" stop-opacity="0.1" />
<stop offset="66%" stop-color="var(--model-trend-active)" stop-opacity="0.045" />
<stop offset="100%" stop-color="var(--model-trend-active)" stop-opacity="0" />
</linearGradient>
</defs>
<path data-slot="model-trend-area" d={area()} fill={`url(#${areaGradientId})`} />
</svg>
)}
</Show>
<svg
data-slot="model-trend-line"
data-layer="base"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<Show
when={activeLineBreak()}
fallback={<path data-slot="model-trend-line-base" data-tone={props.lineTone} d={path()} />}
>
{(lineBreak) => (
<>
<defs>
<mask id={activeLineMaskId} maskUnits="userSpaceOnUse">
<rect x="0" y="-2" width="100" height="104" fill="white" />
<rect x={lineBreak().x} y="-2" width={lineBreak().width} height="104" fill="black" />
</mask>
</defs>
<path
data-slot="model-trend-line-base"
data-tone={props.activeLineBaseTone ?? props.lineTone}
d={path()}
mask={`url(#${activeLineMaskId})`}
/>
</>
)}
</Show>
</svg>
<Show when={activeLineClip()}>
{(clip) => (
<svg
data-slot="model-trend-line"
data-layer="active"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<clipPath id={activeLineClipId} clipPathUnits="userSpaceOnUse">
<rect x={clip().x} y="-2" width={clip().width} height="104" />
</clipPath>
</defs>
<path data-slot="model-trend-line-active" d={path()} clip-path={`url(#${activeLineClipId})`} />
</svg>
)}
</Show>
</>
)}
</Show>
<Show when={linePoints().at(-1)}>
{(point) => (
<span
data-slot="model-trend-end-marker"
data-tone={props.lineTone}
aria-hidden="true"
style={
{
"--model-trend-end-x": `${point().x}%`,
"--model-trend-end-top": `${point().y * lineScale}%`,
} as JSX.CSSProperties
}
/>
)}
</Show>
<div data-slot="model-trend-bars">
<For each={usage()}>
{(point, index) => (
<div
data-slot="model-trend-column"
role="button"
tabIndex={0}
aria-label={`${point.date} ${props.formatValue(props.value(point))} ${props.valueUnit}`}
data-active={activeIndex() === index() ? "true" : undefined}
data-muted={
highlightBars() && activeIndex() !== undefined && activeIndex() !== index() ? "true" : undefined
}
style={
{
"--model-trend-token-height": modelUsageStripHeight(props.value(point), valueMax()),
} as JSX.CSSProperties
}
onPointerDown={(event) => {
if (event.pointerType !== "touch") return
setActiveIndex(index())
}}
onPointerEnter={() => setActiveIndex(index())}
onPointerMove={(event) => {
if (event.pointerType === "touch") return
setActiveIndex(index())
}}
onClick={() => setActiveIndex(index())}
onFocus={() => setActiveIndex(index())}
onBlur={() => setActiveIndex(undefined)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
setActiveIndex(index())
}}
>
<div data-slot="model-trend-token-band">
<div data-slot="model-trend-token-bar" />
</div>
</div>
)}
</For>
</div>
<Show when={activeTooltip()} keyed>
{(active) => (
<div
data-component="chart-tooltip"
data-placement={active.index > usage().length * 0.62 ? "left" : "right"}
style={
{
"--model-trend-tooltip-left": `${active.bounds.x}%`,
"--model-trend-tooltip-right": `${active.bounds.x + active.bounds.width}%`,
"--model-trend-tooltip-y": `${active.tooltipY}`,
} as JSX.CSSProperties
}
>
<strong>{formatModelUsageTooltipDate(active.point.date, props.data?.updatedAt ?? null)}</strong>
<span>
{props.formatValue(props.value(active.point))} {props.valueUnit}
</span>
<div data-slot="tooltip-divider" />
<p>
<span data-slot="tooltip-label">
<i data-kind={props.lineTone === "active" ? "users" : "tokens"} /> {props.rowLabel}
</span>
<b>{props.formatValue(props.value(active.point))}</b>
</p>
</div>
)}
</Show>
</div>
<div data-slot="model-trend-months" aria-hidden="true">
<For each={monthTicks()}>
{(tick) => (
<span
data-align={tick.align}
style={{ "--model-trend-month-left": `${tick.left}%` } as JSX.CSSProperties}
>
{tick.label}
</span>
)}
</For>
</div>
</div>
</Show>
</section>
)
}
function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) {
const i18n = useI18n()
return (
@@ -880,32 +580,35 @@ function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: M
}
>
{(data) => (
<>
<div data-slot="model-efficiency-pattern" aria-hidden="true" />
<div data-component="model-efficiency-grid">
<MetricCard label={i18n.t("model.totalSpendLabel")} value={formatMoney(data().totals.cost)} />
<MetricCard
label={i18n.t("model.costInput")}
value={formatCatalogUnitPrice(props.catalog?.cost?.input)}
/>
<MetricCard
label={i18n.t("model.costOutput")}
value={formatCatalogUnitPrice(props.catalog?.cost?.output)}
/>
<MetricCard
label={i18n.t("model.averageCostSession")}
value={formatSessionCost(data().totals.costPerSession)}
/>
<MetricCard
label={i18n.t("model.averageTokensSession")}
value={formatTokens(data().totals.tokensPerSession)}
/>
<MetricCard
label={i18n.t("model.cacheRatio")}
value={`${formatPercent(data().totals.cacheRatio)} ${i18n.t("model.inputTokens")}`}
/>
</div>
</>
<div data-component="model-metric-grid" data-variant="dense">
<MetricCard
label={i18n.t("model.cost")}
value={formatMoney(data().totals.cost)}
detail={i18n.t("model.totalSpend")}
/>
<MetricCard
label={i18n.t("model.costPerMillion")}
value={
props.catalog?.cost ? formatCatalogPrice(props.catalog.cost) : formatMoney(data().totals.costPerMillion)
}
detail={props.catalog?.cost ? i18n.t("model.inputOutput") : i18n.t("model.observedTokens")}
/>
<MetricCard
label={i18n.t("model.costSession")}
value={formatSessionCost(data().totals.costPerSession)}
detail={i18n.t("model.average")}
/>
<MetricCard
label={i18n.t("model.tokensSession")}
value={formatTokens(data().totals.tokensPerSession)}
detail={i18n.t("model.average")}
/>
<MetricCard
label={i18n.t("model.cacheRatio")}
value={formatPercent(data().totals.cacheRatio)}
detail={i18n.t("model.inputTokens")}
/>
</div>
)}
</Show>
</section>
@@ -1125,12 +828,12 @@ function ModelPeersSection(props: { data: StatsModelData | null }) {
)
}
function MetricCard(props: { label: string; value: string; detail?: string; state?: "positive" | "negative" }) {
function MetricCard(props: { label: string; value: string; detail: string; state?: "positive" | "negative" }) {
return (
<article data-component="model-metric" data-state={props.state}>
<span>{props.label}</span>
<strong>{props.value}</strong>
<Show when={props.detail}>{(detail) => <p>{detail()}</p>}</Show>
<p>{props.detail}</p>
</article>
)
}
@@ -1143,16 +846,10 @@ function PeerRow(props: { peer: ModelPeerEntry; active: boolean }) {
href={language.route(`${import.meta.env.BASE_URL}${providerSlug(props.peer.provider)}/${props.peer.slug}`)}
data-active={props.active ? "true" : undefined}
>
<span data-slot="model-peer-rank" aria-label={props.active ? `Rank ${props.peer.rank}` : undefined}>
<Show when={!props.active}>{String(props.peer.rank).padStart(2, "0")}</Show>
</span>
<span data-slot="model-peer-avatar">
<ProviderIcon aria-hidden="true" id={getProviderIconId(props.peer.author)} />
</span>
<span data-slot="model-peer-copy">
<strong>{props.peer.model}</strong>
<em>{props.peer.author}</em>
</span>
<span>{String(props.peer.rank).padStart(2, "0")}</span>
<ProviderIcon aria-hidden="true" id={getProviderIconId(props.peer.author)} />
<strong>{props.peer.model}</strong>
<em>{props.peer.author}</em>
<b>{formatTokens(props.peer.tokens)}</b>
</a>
</li>
@@ -1317,101 +1014,6 @@ function formatMomentumDateLabel(date: string) {
return `${shortMonths[parsed.getUTCMonth()]} ${parsed.getUTCDate()}`
}
function modelUsageStripHeight(value: number, max: number) {
if (value <= 0 || max <= 0) return "0px"
return `max(2px, ${(value / max) * 100}%)`
}
function modelUsageLineY(value: number, max: number) {
if (value <= 0 || max <= 0) return 100
return Math.max(0, 100 - (value / max) * 100)
}
function modelUsagePointX(index: number, count: number) {
if (count <= 1) return 50
return ((index + 0.5) / count) * 100
}
function modelUsageColumnBounds(index: number, count: number) {
if (count <= 0) return { x: 0, width: 100 }
return { x: (index / count) * 100, width: 100 / count }
}
function modelUsageColumnInnerBounds(index: number, count: number) {
const bounds = modelUsageColumnBounds(index, count)
const inset = Math.min(bounds.width * 0.1, 0.24)
return { x: bounds.x + inset, width: Math.max(0.001, bounds.width - inset * 2) }
}
type ModelUsageLinePoint = { x: number; y: number }
function modelUsageLinePath(points: ModelUsageLinePoint[]) {
if (points.length === 0) return ""
if (points.length === 1) return `M ${formatUsagePathNumber(points[0].x)} ${formatUsagePathNumber(points[0].y)}`
return points.slice(0, -1).reduce(
(path, point, index) => {
const next = points[index + 1]
const previous = points[index - 1] ?? point
const afterNext = points[index + 2] ?? next
const controlA = {
x: point.x + (next.x - previous.x) / 6,
y: clampUsagePercent(point.y + (next.y - previous.y) / 6),
}
const controlB = {
x: next.x - (afterNext.x - point.x) / 6,
y: clampUsagePercent(next.y - (afterNext.y - point.y) / 6),
}
return `${path} C ${formatUsagePathNumber(controlA.x)} ${formatUsagePathNumber(controlA.y)}, ${formatUsagePathNumber(controlB.x)} ${formatUsagePathNumber(controlB.y)}, ${formatUsagePathNumber(next.x)} ${formatUsagePathNumber(next.y)}`
},
`M ${formatUsagePathNumber(points[0].x)} ${formatUsagePathNumber(points[0].y)}`,
)
}
function modelUsageAreaPath(points: ModelUsageLinePoint[], bottom: number) {
const path = modelUsageLinePath(points)
if (!path || points.length === 0) return ""
const first = points[0]
const last = points[points.length - 1]
return `${path} L ${formatUsagePathNumber(last.x)} ${formatUsagePathNumber(bottom)} L ${formatUsagePathNumber(first.x)} ${formatUsagePathNumber(bottom)} Z`
}
function formatUsagePathNumber(value: number) {
return Number(value.toFixed(3))
}
function clampUsagePercent(value: number) {
return Math.max(0, Math.min(100, value))
}
function modelUsageMonthTicks(points: ModelUsagePoint[], updatedAt: string | null) {
const seen = new Set<string>()
return points.flatMap((point, index) => {
const parsed = parseMomentumDate(point.date, updatedAt)
const label = shortMonths[parsed.getUTCMonth()]
if (!label || seen.has(label)) return []
seen.add(label)
return [
{
label,
left: modelUsagePointX(index, points.length),
align: index === 0 ? "start" : index >= points.length - 2 ? "end" : "center",
},
]
})
}
function formatModelUsageTooltipDate(value: string, updatedAt: string | null) {
const date = parseMomentumDate(value, updatedAt)
if (date.getUTCFullYear() === 1970) return value
return new Intl.DateTimeFormat("en", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(date)
}
function formatRankLabel(rank: number | null) {
if (rank === null) return "--"
return `#${String(rank).padStart(2, "0")}`
@@ -1519,9 +1121,8 @@ function formatMoney(value: number) {
return `$${value.toFixed(value >= 10 ? 0 : 2)}`
}
function formatCatalogUnitPrice(value: number | undefined) {
if (value === undefined) return "-"
return `${formatModelPrice(value)} / 1M`
function formatCatalogPrice(value: ModelCatalogCost) {
return `${formatModelPrice(value.input)} / ${formatModelPrice(value.output)}`
}
function formatModelPrice(value: number) {
+18 -436
View File
@@ -3913,50 +3913,6 @@
color: #b82d35;
}
[data-page="stats"] [data-slot="model-efficiency-pattern"] {
width: 100%;
height: 16px;
margin-bottom: 40px;
overflow: hidden;
background: color-mix(in srgb, var(--stats-text) 10%, transparent);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-position: center top;
mask-repeat: repeat;
mask-size: 6px 6px;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-position: center top;
-webkit-mask-repeat: repeat;
-webkit-mask-size: 6px 6px;
}
[data-page="stats"] [data-component="model-efficiency-grid"] {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
[data-page="stats"] [data-component="model-efficiency-grid"] [data-component="model-metric"] {
display: flex;
align-items: flex-start;
flex-direction: column;
box-sizing: border-box;
gap: 0;
height: 120px;
min-height: 120px;
padding: 16px;
background: var(--stats-layer);
}
[data-page="stats"] [data-component="model-efficiency-grid"] [data-component="model-metric"] span {
line-height: 14px;
}
[data-page="stats"] [data-component="model-efficiency-grid"] [data-component="model-metric"] strong {
margin-top: 32px;
font-size: 28px;
line-height: 42px;
}
[data-page="stats"] [data-section="model-panel"][data-variant="lab-models"] {
overflow: hidden;
}
@@ -4915,254 +4871,9 @@
background: var(--lab-usage-line);
}
[data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] {
--model-trend-gap: 4px;
--model-trend-column-gutter: calc(var(--model-trend-gap) / 2);
--model-trend-bar-height: 76px;
--model-trend-line-bottom: 124px;
--model-trend-bar: #dbdbdb;
--model-trend-line: #808080;
--model-trend-active: #3b5cf6;
--model-trend-band: #f2f2f2;
grid-template-rows: minmax(0, 450px) 14px;
gap: 40px;
height: 504px;
}
[data-page="stats"] [data-slot="model-trend-plot"] {
position: relative;
min-width: 0;
min-height: 0;
overflow: visible;
}
[data-page="stats"] [data-slot="model-trend-plot"]::after {
position: absolute;
inset: 0;
z-index: 0;
background: linear-gradient(to bottom, transparent 42%, color-mix(in srgb, var(--stats-text) 3%, transparent));
content: "";
pointer-events: none;
}
[data-page="stats"] [data-slot="model-trend-line"] {
position: absolute;
top: 0;
right: 0;
bottom: auto;
left: 0;
display: block;
width: 100%;
height: calc(100% - var(--model-trend-line-bottom));
overflow: visible;
pointer-events: none;
}
[data-page="stats"] [data-slot="model-trend-line"][data-layer="base"] {
z-index: 2;
}
[data-page="stats"] [data-slot="model-trend-line"][data-layer="active"] {
z-index: 4;
}
[data-page="stats"] [data-slot="model-trend-area-layer"] {
position: absolute;
inset: 0;
z-index: 1;
display: block;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
[data-page="stats"] [data-slot="model-trend-area-layer"] [data-slot="model-trend-area"] {
stroke: none;
}
[data-page="stats"] [data-slot="model-trend-line"] path {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
[data-page="stats"] [data-slot="model-trend-line-base"] {
stroke: var(--model-trend-line);
}
[data-page="stats"] [data-slot="model-trend-line-base"][data-tone="active"] {
stroke: var(--model-trend-active);
}
[data-page="stats"] [data-slot="model-trend-line-active"] {
stroke: var(--model-trend-active);
}
[data-page="stats"] [data-slot="model-trend-end-marker"] {
position: absolute;
top: var(--model-trend-end-top);
left: var(--model-trend-end-x);
z-index: 3;
width: 6px;
height: 6px;
background: var(--model-trend-bar);
transform: translate(-50%, -50%);
pointer-events: none;
}
[data-page="stats"] [data-slot="model-trend-end-marker"][data-tone="active"] {
background: var(--model-trend-active);
}
[data-page="stats"] [data-slot="model-trend-bars"] {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
gap: 0;
min-width: 0;
}
[data-page="stats"] [data-slot="model-trend-column"] {
position: relative;
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
height: 100%;
outline: none;
cursor: pointer;
}
[data-page="stats"] [data-slot="model-trend-column"]::before {
position: absolute;
inset: 0;
z-index: 1;
background: transparent;
content: "";
pointer-events: none;
transition: background 120ms ease;
}
[data-page="stats"] [data-slot="model-trend-column"][data-active="true"]::before,
[data-page="stats"] [data-slot="model-trend-column"]:focus-visible::before {
background: var(--model-trend-band);
box-shadow:
inset var(--model-trend-column-gutter) 0 0 var(--stats-bg),
inset calc(var(--model-trend-column-gutter) * -1) 0 0 var(--stats-bg);
}
[data-page="stats"] [data-slot="model-trend-token-band"] {
position: absolute;
right: var(--model-trend-column-gutter);
bottom: 0;
left: var(--model-trend-column-gutter);
z-index: 2;
height: var(--model-trend-bar-height);
}
[data-page="stats"] [data-slot="model-trend-token-bar"] {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: var(--model-trend-token-height);
background: var(--model-trend-bar);
transition:
background 120ms ease,
opacity 120ms ease;
}
[data-page="stats"]
[data-component="model-usage-chart"][data-highlight-bars="true"]
[data-slot="model-trend-column"][data-active="true"]
[data-slot="model-trend-token-bar"] {
background: var(--model-trend-active);
}
[data-page="stats"]
[data-component="model-usage-chart"][data-highlight-bars="true"]
[data-slot="model-trend-column"][data-muted="true"]
[data-slot="model-trend-token-bar"] {
opacity: 0.64;
}
[data-page="stats"] [data-slot="model-trend-months"] {
position: relative;
height: 14px;
color: var(--stats-muted);
font-size: 11px;
font-weight: 500;
line-height: 14px;
}
[data-page="stats"] [data-slot="model-trend-months"] span {
position: absolute;
top: 0;
left: var(--model-trend-month-left);
transform: translateX(-50%);
}
[data-page="stats"] [data-slot="model-trend-months"] span[data-align="start"] {
transform: none;
}
[data-page="stats"] [data-slot="model-trend-months"] span[data-align="end"] {
transform: translateX(-100%);
}
[data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] [data-component="chart-tooltip"] {
z-index: 6;
top: clamp(12px, calc(var(--model-trend-tooltip-y) * 1% - 46px), calc(100% - 108px));
width: 192px;
min-width: 192px;
background: var(--stats-bg);
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"][data-placement="right"] {
right: auto;
left: calc(var(--model-trend-tooltip-right) + 8px);
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"][data-placement="left"] {
right: calc(100% - var(--model-trend-tooltip-left) + 8px);
left: auto;
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"]
p {
height: 16px;
margin: 8px 0;
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"]
i[data-kind="tokens"] {
background: var(--model-trend-active);
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"]
i[data-kind="users"] {
background: var(--model-trend-active);
}
[data-page="stats"] [data-component="model-peer-list"] {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
grid-template-rows: repeat(5, 64px);
gap: 12px 40px;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
@@ -5170,12 +4881,11 @@
[data-page="stats"] [data-component="model-peer-list"] a {
display: grid;
grid-template-columns: 32px 32px minmax(0, 1fr) minmax(64px, auto);
gap: 16px;
grid-template-columns: 38px 24px minmax(0, 1fr) minmax(96px, auto) minmax(80px, auto);
gap: 12px;
align-items: center;
box-sizing: border-box;
height: 64px;
padding: 15px 16px;
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--stats-line);
background: var(--stats-layer);
color: var(--stats-text);
@@ -5184,56 +4894,25 @@
[data-page="stats"] [data-component="model-peer-list"] a:hover,
[data-page="stats"] [data-component="model-peer-list"] a[data-active="true"] {
border-color: var(--stats-text);
border-color: var(--stats-line-strong);
background: var(--stats-bg);
text-decoration: none;
}
[data-page="stats"] [data-slot="model-peer-rank"],
[data-page="stats"] [data-component="model-peer-list"] span,
[data-page="stats"] [data-component="model-peer-list"] em,
[data-page="stats"] [data-component="model-peer-list"] b {
color: var(--stats-muted);
font-size: 13px;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 16px;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
[data-page="stats"] [data-slot="model-peer-rank"] {
position: relative;
display: grid;
place-items: center;
width: 32px;
height: 32px;
}
[data-page="stats"] [data-component="model-peer-list"] a[data-active="true"] [data-slot="model-peer-rank"]::before {
width: 6px;
height: 6px;
content: "";
background: var(--stats-text);
}
[data-page="stats"] [data-slot="model-peer-avatar"] {
display: grid;
place-items: center;
width: 32px;
height: 32px;
color: var(--stats-muted);
background: var(--stats-bg);
box-shadow: inset 0 0 0 1px var(--stats-line);
}
[data-page="stats"] [data-slot="model-peer-avatar"] svg {
[data-page="stats"] [data-component="model-peer-list"] svg {
width: 18px;
height: 18px;
}
[data-page="stats"] [data-slot="model-peer-copy"] {
display: grid;
gap: 2px;
min-width: 0;
color: var(--stats-muted);
}
[data-page="stats"] [data-component="model-peer-list"] strong {
@@ -5242,16 +4921,11 @@
color: var(--stats-text);
font-size: 13px;
font-weight: 600;
line-height: 16px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] [data-component="model-peer-list"] b {
justify-self: end;
color: var(--stats-text);
}
[data-page="stats"][data-theme="dark"],
:root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) {
color-scheme: dark;
@@ -5292,15 +4966,6 @@
--lab-usage-band: #242424;
}
[data-page="stats"][data-theme="dark"] [data-component="model-usage-chart"][data-variant="model-trend"],
:root[data-stats-theme="dark"]
[data-page="stats"]:not([data-theme="light"])
[data-component="model-usage-chart"][data-variant="model-trend"] {
--model-trend-bar: #3a3a3a;
--model-trend-line: #a3a3a3;
--model-trend-band: #242424;
}
[data-page="stats"][data-theme="dark"] [data-component="chart-tooltip"],
:root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) [data-component="chart-tooltip"] {
background: #242424f2;
@@ -5466,14 +5131,6 @@
--lab-usage-band: #242424;
}
:root:not([data-stats-theme="light"])
[data-page="stats"]:not([data-theme="light"])
[data-component="model-usage-chart"][data-variant="model-trend"] {
--model-trend-bar: #3a3a3a;
--model-trend-line: #a3a3a3;
--model-trend-band: #242424;
}
:root:not([data-stats-theme="light"]) [data-page="stats"]:not([data-theme="light"]) [data-component="chart-tooltip"] {
background: #242424f2;
}
@@ -5572,27 +5229,15 @@
[data-page="stats"][data-theme="dark"]
[data-component="model-usage-chart"][data-variant="lab-usage"]
[data-component="chart-tooltip"],
[data-page="stats"][data-theme="dark"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"],
:root[data-stats-theme="dark"]
[data-page="stats"]:not([data-theme="light"])
[data-component="model-usage-chart"][data-variant="lab-usage"]
[data-component="chart-tooltip"],
:root[data-stats-theme="dark"]
[data-page="stats"]:not([data-theme="light"])
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"],
:root:not([data-stats-theme="light"])
[data-page="stats"]:not([data-theme="light"])
[data-component="model-usage-chart"][data-variant="lab-usage"]
[data-component="chart-tooltip"],
:root:not([data-stats-theme="light"])
[data-page="stats"]:not([data-theme="light"])
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"],
[data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-component="chart-tooltip"],
[data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] [data-component="chart-tooltip"] {
[data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-component="chart-tooltip"] {
background: var(--stats-bg);
}
@@ -5783,18 +5428,16 @@
[data-page="stats"] [data-component="model-metric-grid"],
[data-page="stats"] [data-component="model-metric-grid"][data-variant="dense"],
[data-page="stats"] [data-component="model-efficiency-grid"],
[data-page="stats"] [data-component="lab-model-grid"] {
grid-template-columns: 1fr 1fr;
}
[data-page="stats"] [data-component="model-peer-list"] a {
grid-template-columns: 32px 32px minmax(0, 1fr) minmax(64px, auto);
gap: 12px;
grid-template-columns: 32px 22px minmax(0, 1fr) minmax(72px, auto);
}
[data-page="stats"] [data-component="model-peer-list"] em {
display: block;
display: none;
}
[data-page="stats"] [data-slot="chart-footer"] {
@@ -5871,13 +5514,6 @@
margin-top: 16px;
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"] {
position: absolute;
margin-top: 0;
}
[data-page="stats"] [data-component="footer"] {
padding: 88px 24px 24px;
}
@@ -6211,7 +5847,6 @@
[data-page="stats"] [data-component="model-metric-grid"],
[data-page="stats"] [data-component="model-metric-grid"][data-variant="dense"],
[data-page="stats"] [data-component="model-efficiency-grid"],
[data-page="stats"] [data-component="lab-model-grid"] {
grid-template-columns: 1fr;
}
@@ -6220,15 +5855,6 @@
min-height: 112px;
}
[data-page="stats"] [data-component="model-efficiency-grid"] [data-component="model-metric"] {
height: 112px;
min-height: 112px;
}
[data-page="stats"] [data-component="model-efficiency-grid"] [data-component="model-metric"] strong {
margin-top: 24px;
}
[data-page="stats"] [data-component="model-usage-chart"] {
--model-usage-mobile-bar-width: 16px;
--model-usage-mobile-edge-space: 48px;
@@ -6309,56 +5935,12 @@
min-width: 0;
}
[data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] {
--model-usage-mobile-bar-width: 20px;
--model-usage-mobile-edge-space: 48px;
--model-usage-mobile-track-width: calc(
var(--model-usage-count) * var(--model-usage-mobile-bar-width) + var(--model-usage-mobile-edge-space)
);
--model-trend-bar-height: 64px;
--model-trend-line-bottom: 104px;
grid-template-rows: minmax(0, 360px) 14px;
gap: 24px;
height: 398px;
}
[data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] [data-slot="model-trend-plot"],
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-slot="model-trend-months"] {
direction: ltr;
width: var(--model-usage-mobile-track-width);
min-width: var(--model-usage-mobile-track-width);
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-slot="model-trend-column"] {
flex: 0 0 var(--model-usage-mobile-bar-width);
}
[data-page="stats"]
[data-component="model-usage-chart"][data-variant="model-trend"]
[data-component="chart-tooltip"] {
top: auto;
width: auto;
min-width: 0;
}
[data-page="stats"] [data-component="model-peer-list"] {
grid-auto-flow: row;
grid-auto-columns: auto;
grid-template-rows: none;
gap: 12px;
}
[data-page="stats"] [data-component="model-peer-list"] a {
grid-template-columns: 28px 32px minmax(0, 1fr) minmax(56px, auto);
gap: 12px;
grid-template-columns: 30px minmax(0, 1fr) minmax(64px, auto);
}
[data-page="stats"] [data-slot="model-peer-rank"] {
width: 28px;
[data-page="stats"] [data-component="model-peer-list"] svg {
display: none;
}
[data-page="stats"] [data-section="top-models"] [data-slot="chart-footer"] {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
"version": "1.17.15",
"version": "1.17.14",
"private": true,
"type": "module",
"license": "MIT",
+2 -2
View File
@@ -646,8 +646,8 @@ function buildModelTokenMix(aggregate: ModelAggregate): ModelMixEntry[] {
}
function buildModelPeers(peers: ModelAggregate[], rank: number, totalTokens: number): ModelPeerEntry[] {
const start = Math.max(0, Math.min(rank - 5, Math.max(peers.length - 10, 0)))
return peers.slice(start, start + 10).map((item, index) => ({
const start = Math.max(0, Math.min(rank - 4, Math.max(peers.length - 7, 0)))
return peers.slice(start, start + 7).map((item, index) => ({
model: item.model,
provider: item.provider,
author: formatProvider(item.provider),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server",
"version": "1.17.15",
"version": "1.17.14",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/tui",
"version": "1.17.15",
"version": "1.17.14",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.17.15",
"version": "1.17.14",
"type": "module",
"license": "MIT",
"repository": {
+7 -10
View File
@@ -173,10 +173,7 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: {
defaultTheme?: string
onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark", scheme: ColorScheme) => void
}) => {
init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => {
const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2"
const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system"
const mode = colorScheme === "system" ? getSystemMode() : colorScheme
@@ -215,9 +212,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
return task
}
const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark", scheme: ColorScheme) => {
const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => {
applyThemeCss(theme, themeId, mode)
props.onThemeApplied?.(theme, mode, scheme)
props.onThemeApplied?.(theme, mode)
}
const ids = () => {
@@ -281,7 +278,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
createEffect(() => {
const theme = store.themes[store.themeId]
if (!theme) return
applyTheme(theme, store.themeId, store.mode, store.colorScheme)
applyTheme(theme, store.themeId, store.mode)
})
const setTheme = (id: string) => {
@@ -336,7 +333,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
? getSystemMode()
: store.previewScheme
: store.mode
applyTheme(theme, next, mode, store.previewScheme ?? store.colorScheme)
applyTheme(theme, next, mode)
})
},
previewColorScheme: (scheme: ColorScheme) => {
@@ -347,7 +344,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
if (!theme) return
if ((store.previewThemeId ?? store.themeId) !== id) return
if (store.previewScheme !== scheme) return
applyTheme(theme, id, mode, scheme)
applyTheme(theme, id, mode)
})
},
commitPreview: () => {
@@ -365,7 +362,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore("previewScheme", null)
void load(store.themeId).then((theme) => {
if (!theme) return
applyTheme(theme, store.themeId, store.mode, store.colorScheme)
applyTheme(theme, store.themeId, store.mode)
})
},
}

Some files were not shown because too many files have changed in this diff Show More