Compare commits

..

11 Commits

Author SHA1 Message Date
opencode-agent[bot] b684cc8746 fix(core): isolate models.dev auto refresh
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-07-03 04:44:48 +00:00
Frank 5455fed78e zen: new inference
deploy / deploy (push) Has been cancelled
2026-07-02 21:59:33 -04:00
Frank 6dba0cc498 zen: new inference 2026-07-02 21:56:52 -04:00
Luke Parker c07ac0db2f feat(app): navigate tabs on mousedown in new layout (#35042) 2026-07-03 11:47:06 +10:00
Frank 20b090e493 zen: new inference 2026-07-02 21:38:29 -04:00
Frank cb54824f2f zen: new inference 2026-07-02 21:28:38 -04:00
Dustin Deus 3adfb970bf feat(tui): add debug info dialog with copy to clipboard (#35004) 2026-07-02 23:00:53 +02:00
opencode-agent[bot] 5ecd19db9f chore: generate 2026-07-02 19:43:04 +00:00
Adam 299daa2815 feat(stats): redesign model hero 2026-07-02 14:41:22 -05:00
Adam 7457139849 fix(stats): polish lab pages 2026-07-02 14:38:16 -05:00
Aiden Cline 373cd08b98 fix(copilot): honor advertised model endpoints (#34958) 2026-07-02 10:23:57 -05:00
29 changed files with 1055 additions and 256 deletions
@@ -0,0 +1,108 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
const server = "http://127.0.0.1:4096"
const sessionA = session("ses_tab_a", "Tab A session")
const sessionB = session("ses_tab_b", "Tab B session")
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
await page.goto(hrefA)
await expect(page.getByText(sessionA.title).first()).toBeVisible()
const linkB = page.locator(`a[data-titlebar-tab-link][href="${hrefB}"]`)
await expect(linkB).toBeVisible()
const box = await linkB.boundingBox()
if (!box) throw new Error("tab link has no bounding box")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
// Navigation must happen on mousedown, before the button is released.
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await page.mouse.up()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
function session(id: string, title: string) {
return {
id,
slug: id,
projectID: "project-tabs",
directory: "C:/tab-project",
title,
version: "dev",
time: { created: 1, updated: 1 },
}
}
async function mockServer(page: Page) {
const sessions = [sessionA, sessionB]
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback()
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/session") return json(route, sessions)
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
if (byId) return json(route, byId)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
return json(route, {})
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") {
const project = {
id: sessionA.projectID,
worktree: sessionA.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/project" ? [project] : project)
}
if (url.pathname === "/path")
return json(route, {
state: sessionA.directory,
config: sessionA.directory,
worktree: sessionA.directory,
directory: sessionA.directory,
home: sessionA.directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
return json(route, {})
})
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(body),
})
}
function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
+1 -3
View File
@@ -218,7 +218,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
let restoreEndOnFocus = true
let savedCursor: number | null = null
const mirror = { input: false }
const inset = 56
@@ -591,7 +590,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const restoreFocus = () => {
requestAnimationFrame(() => {
const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current())
const cursor = prompt.cursor() ?? promptLength(prompt.current())
editorRef.focus()
setCursorPosition(editorRef, cursor)
queueScroll()
@@ -628,7 +627,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => {
savedCursor = currentCursor()
closePopover()
setComposing(false)
}
@@ -399,7 +399,7 @@
overflow: hidden;
font-size: 13px;
font-weight: 440;
line-height: 16px;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -229,8 +229,17 @@ export function TabNavItem(props: {
event.preventDefault()
event.stopPropagation()
}}
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
@@ -368,8 +377,16 @@ export function DraftTabItem(props: {
event.preventDefault()
event.stopPropagation()
}}
onMouseDown={(event) => {
// Navigate on mousedown to shave the press-release delay off tab switches.
if (event.button !== 0) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
onClick={(event) => {
event.preventDefault()
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
if (event.detail > 0) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
@@ -3,7 +3,7 @@ import { createSignal, Show, type JSXElement } from "solid-js"
import "./titlebar-tab-popover.css"
// Initial hover delay before the preview appears, per design.
const OPEN_DELAY = 400
const OPEN_DELAY = 200
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
const CLOSE_DELAY = 0
// After a preview closes, hovering a neighbouring tab within this window skips
@@ -1393,14 +1393,14 @@ export function MessageTimeline(props: {
<button
type="button"
data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
class="min-w-0 max-w-[40%] truncate px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={navigateParent}
>
{parentTitle()}
</button>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
class="-translate-y-[0.5px] px-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
@@ -3,7 +3,6 @@ import { useLanguage } from "@/context/language"
import { useLocal } from "@/context/local"
import { useSettings } from "@/context/settings"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
import { useSessionLayout } from "./session-layout"
import { createSessionOwnership } from "./session-ownership"
@@ -27,23 +26,9 @@ export const useComposerCommands = () => {
const chooseModel = async () => {
const owner = sessionOwnership.capture()
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
const selection = window.getSelection()
const cursor =
editor && selection?.rangeCount && editor.contains(selection.anchorNode) ? getCursorPosition(editor) : null
const restoreComposer = () => {
// Kobalte restores focus during its teardown effect; defer past it so the
// composer keeps focus and the caret returns to where the user left it.
requestAnimationFrame(() => {
const editor = document.querySelector<HTMLElement>('[data-component="prompt-input"]')
if (!editor) return
editor.focus()
if (cursor !== null) setCursorPosition(editor, cursor)
})
}
const { DialogSelectModel } = await import("@/components/dialog-select-model")
owner.run(() => {
void dialog.show(() => <DialogSelectModel model={local.model} />, restoreComposer)
void dialog.show(() => <DialogSelectModel model={local.model} />)
})
}
@@ -179,6 +179,8 @@ export async function handler(
logger.metric({
provider: providerInfo.id,
"provider.model": providerInfo.model,
shallowProvider: providerInfo.id,
"shallowProvider.model": providerInfo.model,
})
const startTimestamp = Date.now()
@@ -210,39 +212,44 @@ export async function handler(
)
logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
const res = await fetchWith429Retry(reqUrl, {
method: "POST",
headers: (() => {
const headers = new Headers(input.request.headers)
providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId)
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
if (v === "$ip") return headers.set(k, ip)
if (v === "$caller") return headers.set(k, stickyId)
if (v === "$session") return headers.set(k, sessionId)
if (v === "$model") return headers.set(k, model)
if (v === "$request") return headers.set(k, requestId)
if (v === "$project") return headers.set(k, projectId)
if (v === "$workspace") {
if (authInfo?.workspaceID) headers.set(k, authInfo.workspaceID)
return
}
headers.set(k, v)
})
headers.delete("host")
headers.delete("content-length")
headers.delete("x-opencode-request")
headers.delete("x-opencode-session")
headers.delete("x-opencode-project")
headers.delete("x-opencode-client")
return headers
})(),
body: reqBody,
// Propagate caller disconnects to the upstream provider request so
// abandoned Console requests do not leave orphaned inference work open.
signal: input.request.signal,
})
const isNewInference = providerInfo.id.startsWith("console.") || providerInfo.id.startsWith("console-go.")
const res = await fetchWith429Retry(
reqUrl,
{
method: "POST",
headers: (() => {
const headers = new Headers(input.request.headers)
providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId)
Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => {
if (v === "$ip") return headers.set(k, ip)
if (v === "$caller") return headers.set(k, stickyId)
if (v === "$session") return headers.set(k, sessionId)
if (v === "$model") return headers.set(k, model)
if (v === "$request") return headers.set(k, requestId)
if (v === "$project") return headers.set(k, projectId)
if (v === "$workspace") {
if (authInfo?.workspaceID) headers.set(k, authInfo.workspaceID)
return
}
headers.set(k, v)
})
headers.delete("host")
headers.delete("content-length")
headers.delete("x-opencode-request")
headers.delete("x-opencode-session")
headers.delete("x-opencode-project")
headers.delete("x-opencode-client")
return headers
})(),
body: reqBody,
// Propagate caller disconnects to the upstream provider request so
// abandoned Console requests do not leave orphaned inference work open.
signal: input.request.signal,
},
{ count: isNewInference ? MAX_429_RETRIES : 0 },
)
if (providerInfo.id.startsWith("console.") || providerInfo.id.startsWith("console-go.")) {
if (isNewInference) {
const resEndpointId = res.headers.get("x-opencode-endpoint-id")
const resEndpointModelId = res.headers.get("x-opencode-upstream-model-id")
if (resEndpointId && resEndpointModelId)
@@ -261,6 +268,7 @@ export async function handler(
// Try another provider => stop retrying if using fallback provider
if (
//!isNewInference &&
res.status !== 200 &&
// ie. 400 error is usually provider error like malformed request
res.status !== 400 &&
+14 -9
View File
@@ -115,7 +115,7 @@ declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
export interface Interface {
readonly get: () => Effect.Effect<Record<string, Provider>>
readonly refresh: (force?: boolean) => Effect.Effect<void>
readonly refresh: (force?: boolean) => Effect.Effect<void, unknown>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
@@ -224,21 +224,26 @@ const layer = Layer.effect(
yield* invalidate
yield* events.publish(Event.Refreshed, {})
}),
).pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
)
})
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
// Schedule.spaced runs the effect once, then waits between completions.
yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore))
}
return Service.of({ get, refresh })
}),
)
export const autoRefreshLayer = Layer.effectDiscard(
Effect.gen(function* () {
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return
if (process.argv.includes("--get-yargs-completions")) return
const svc = yield* Service
const refresh = svc.refresh().pipe(
Effect.catchCause((cause) => Effect.logWarning("Failed to refresh models.dev catalog", { cause })),
)
// Schedule.spaced runs the effect once, then waits between completions.
yield* Effect.forkScoped(refresh.pipe(Effect.repeat(Schedule.spaced("60 minutes"))))
}),
)
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
export * as ModelsDev from "./models-dev"
@@ -1,19 +1,11 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
function shouldUseResponses(modelID: string) {
// Copilot supports Responses for GPT-5 class models, except mini variants
// which still need the chat-completions endpoint.
const match = /^gpt-(\d+)/.exec(modelID)
if (!match) return false
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
}
export const GithubCopilotPlugin = define({
export const GithubCopilotPlugin = {
id: "github-copilot",
effect: Effect.fn(function* (ctx) {
effect: Effect.fn(function* (ctx: PluginContext) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
@@ -39,10 +31,22 @@ export const GithubCopilotPlugin = define({
evt.language = evt.sdk.languageModel(evt.model.api.id)
return
}
evt.language = shouldUseResponses(evt.model.api.id)
? evt.sdk.responses(evt.model.api.id)
: evt.sdk.chat(evt.model.api.id)
if (evt.options.endpoint === "responses" && evt.sdk.responses) {
evt.language = evt.sdk.responses(evt.model.api.id)
return
}
if (evt.options.endpoint === "chat" && evt.sdk.chat) {
evt.language = evt.sdk.chat(evt.model.api.id)
return
}
const match = /^gpt-(\d+)/.exec(evt.model.api.id)
// Copilot supports Responses for GPT-5 class models, except mini variants
// which still need the chat-completions endpoint.
evt.language =
match && Number(match[1]) >= 5 && !evt.model.api.id.startsWith("gpt-5-mini") && evt.sdk.responses
? evt.sdk.responses(evt.model.api.id)
: evt.sdk.chat(evt.model.api.id)
}),
)
}),
})
}
+59 -11
View File
@@ -3,7 +3,6 @@ import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "@opencode-ai/core/models-dev"
@@ -13,7 +12,7 @@ import path from "path"
// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
// resolve providers without network. These tests need to drive the on-disk
// cache themselves and silence the eager refresh fork. Save/restore around
// cache themselves and control background refresh. Save/restore around
// the suite — never leak the mutation to subsequent test files in the same
// bun process.
const ORIGINAL_MODELS_PATH = Flag.OPENCODE_MODELS_PATH
@@ -97,6 +96,9 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
]),
)
const buildAutoRefreshLayer = (state: Ref.Ref<MockState>) =>
ModelsDev.autoRefreshLayer.pipe(Layer.provideMerge(buildLayer(state)))
const writeCacheText = (text: string, mtimeMs?: number) =>
Effect.promise(async () => {
await mkdir(Global.Path.cache, { recursive: true })
@@ -269,22 +271,68 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh swallows HTTP errors and leaves cache intact", () =>
it.live("refresh surfaces HTTP errors and leaves cache intact", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
const result = yield* provided(
state,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
yield* svc.refresh(true)
return yield* svc.get()
}),
const result = yield* Effect.exit(
provided(
state,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
yield* svc.refresh(true)
return yield* svc.get()
}),
),
)
expect(result).toEqual(fixture)
expect(result._tag).toBe("Failure")
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture))
// retryTransient retries 5xx, so calls may be > 1.
const final = yield* Ref.get(state)
expect(final.calls.length).toBeGreaterThanOrEqual(1)
}),
)
it.live("constructing the service does not start a background refresh", () =>
Effect.gen(function* () {
const state = yield* Ref.make(initialState)
yield* Effect.acquireUseRelease(
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
}),
() => Layer.build(buildLayer(state)).pipe(Effect.andThen(Effect.sleep("20 millis"))),
() =>
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
}),
)
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
)
it.live("autoRefreshLayer starts best-effort background refresh", () =>
Effect.gen(function* () {
yield* writeCache(fixture, Date.now() - 10 * 60 * 1000)
const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
const result = yield* Effect.acquireUseRelease(
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
}),
() =>
Effect.gen(function* () {
const context = yield* Layer.build(buildAutoRefreshLayer(state))
yield* Effect.sleep("700 millis")
return yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
}),
() =>
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
}),
)
expect(result).toEqual(fixture)
const final = yield* Ref.get(state)
expect(final.calls.length).toBeGreaterThanOrEqual(1)
}),
)
})
@@ -157,6 +157,42 @@ describe("GithubCopilotPlugin", () => {
}),
)
it.effect("uses advertised Copilot endpoint metadata before model ID fallbacks", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const aisdk = yield* AISDK.Service
const calls: string[] = []
yield* addPlugin()
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")),
api: {
id: ModelV2.ID.make("mai-code-1-flash-picker"),
type: "aisdk",
package: "test-provider",
settings: { endpoint: "responses" },
},
}),
sdk: fakeSelectorSdk(calls),
options: { endpoint: "responses" },
})
yield* aisdk.runLanguage({
model: ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
api: {
id: ModelV2.ID.make("gpt-5"),
type: "aisdk",
package: "test-provider",
settings: { endpoint: "chat" },
},
}),
sdk: fakeSelectorSdk(calls),
options: { endpoint: "chat" },
})
expect(calls).toEqual(["responses:mai-code-1-flash-picker", "chat:gpt-5"])
}),
)
it.effect("uses the API model ID when selecting responses or chat", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
+6 -3
View File
@@ -12,10 +12,12 @@ export const id = ProviderID.make("github-copilot")
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL: string
readonly endpoint?: "chat" | "responses"
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const shouldUseResponsesApi = (modelID: string | ModelID) => {
export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: ModelOptions["endpoint"]) => {
if (endpoint) return endpoint === "responses"
const model = String(modelID)
const match = /^gpt-(\d+)/.exec(model)
if (!match) return false
@@ -28,7 +30,7 @@ const chatRoute = OpenAIChat.route.with({ provider: id })
const responsesRoute = OpenAIResponses.route.with({ provider: id })
const defaults = (options: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL: _baseURL, ...rest } = options
const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options
return rest
}
@@ -53,7 +55,8 @@ export const configure = (options: ModelOptions) => {
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
return {
id,
model: (modelID: string | ModelID) => (shouldUseResponsesApi(modelID) ? responses(modelID) : chat(modelID)),
model: (modelID: string | ModelID) =>
shouldUseResponsesApi(modelID, options.endpoint) ? responses(modelID) : chat(modelID),
responses,
chat,
configure,
+14
View File
@@ -50,6 +50,20 @@ describe("public exports", () => {
expect(
GitHubCopilot.configure({ baseURL: "https://api.githubcopilot.test", apiKey: "fixture" }).model,
).toBeFunction()
expect(
GitHubCopilot.configure({
baseURL: "https://api.githubcopilot.test",
apiKey: "fixture",
endpoint: "responses",
}).model("mai-code-1-flash-picker").route.id,
).toBe("openai-responses")
expect(
GitHubCopilot.configure({
baseURL: "https://api.githubcopilot.test",
apiKey: "fixture",
endpoint: "chat",
}).model("gpt-5").route.id,
).toBe("openai-chat")
})
test("protocol barrels expose supported low-level routes", () => {
+3 -1
View File
@@ -26,7 +26,9 @@ export const ModelsCommand = effectCmd({
handler: Effect.fn("Cli.models")(function* (args) {
const { Provider } = yield* Effect.promise(() => import("@/provider/provider"))
if (args.refresh) {
yield* ModelsDev.Service.use((s) => s.refresh(true))
yield* ModelsDev.Service.use((s) => s.refresh(true)).pipe(
Effect.catch((error) => fail(`Failed to refresh models cache: ${String(error)}`)),
)
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
}
+8 -2
View File
@@ -55,7 +55,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilderV1 } from "./app-node-builder-v1"
import { SessionProjector } from "@opencode-ai/core/session/projector"
export const AppLayer = AppNodeBuilderV1.build(
const app = AppNodeBuilderV1.build(
LayerNode.group([
Npm.node,
FSUtil.node,
@@ -106,7 +106,13 @@ export const AppLayer = AppNodeBuilderV1.build(
ShareNext.node,
SessionShare.node,
]),
).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer))
)
export const AppLayer = ModelsDev.autoRefreshLayer.pipe(
Layer.provideMerge(app),
Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)),
Layer.provideMerge(Observability.layer),
)
const rt = ManagedRuntime.make(AppLayer, { memoMap })
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
@@ -72,6 +72,10 @@ type SelectableItem = Item & {
}
}
}
type CopilotEndpoint = "chat" | "responses" | "messages"
type CopilotModel = Omit<Model, "api"> & {
api: Model["api"] & { endpoint?: CopilotEndpoint }
}
const decodeModels = Schema.decodeUnknownSync(schema)
const decodeItem = Schema.decodeUnknownOption(item)
@@ -86,17 +90,25 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model):
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
const endpoint: CopilotEndpoint | undefined = isMsgApi
? "messages"
: remote.supported_endpoints?.includes("/responses")
? "responses"
: remote.supported_endpoints?.includes("/chat/completions")
? "chat"
: undefined
const prices = remote.billing?.token_prices
// Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens.
const usdPerMillion = prices ? 10_000 / prices.batch_size : 0
const model: Model = {
const model: CopilotModel = {
id: key,
providerID: "github-copilot",
api: {
id: remote.id,
url: isMsgApi ? `${url}/v1` : url,
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
...(endpoint ? { endpoint } : {}),
},
// API response wins
status: "active",
+5 -1
View File
@@ -218,8 +218,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
"github-copilot": () =>
Effect.succeed({
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
async getModel(sdk: any, modelID: string, _options?: Record<string, any>, model?: Model) {
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
if (model && "endpoint" in model.api) {
if (model.api.endpoint === "responses" && sdk.responses) return sdk.responses(modelID)
if (model.api.endpoint === "chat" && sdk.chat) return sdk.chat(modelID)
}
const match = /^gpt-(\d+)/.exec(modelID)
if (match && Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")) return sdk.responses(modelID)
return sdk.chat(modelID)
@@ -292,6 +292,7 @@ export function createRoutes(
HttpServer.layerServices,
]),
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
Layer.provideMerge(ModelsDev.autoRefreshLayer.pipe(Layer.provide(AppNodeBuilderV1.build(app)))),
Layer.provideMerge(Observability.layer),
Layer.provide(sessionLocationLayer),
@@ -187,6 +187,44 @@ test("converts Copilot AIC token prices to USD per million tokens", async () =>
expect(models["ignored-non-chat-record"]).toBeUndefined()
})
test("records Copilot advertised responses endpoint for non-GPT model IDs", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: [
{
model_picker_enabled: true,
id: "mai-code-1-flash-picker",
name: "MAI-Code-1-Flash",
version: "mai-code-1-flash-picker",
supported_endpoints: ["/responses"],
capabilities: {
family: "oswe-vscode-modelD",
limits: {
max_context_window_tokens: 256000,
max_output_tokens: 128000,
max_prompt_tokens: 128000,
},
supports: {
streaming: true,
structured_outputs: true,
tool_calls: true,
},
},
},
],
}),
{ status: 200 },
),
),
) as unknown as typeof fetch
const model = (await CopilotModels.get("https://api.githubcopilot.com")).models["mai-code-1-flash-picker"]
expect("endpoint" in model.api ? model.api.endpoint : undefined).toBe("responses")
})
test("clears existing variants so refreshed models calculate provider-specific variants", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
+178 -112
View File
@@ -27,6 +27,7 @@ import {
findModelCatalogEntry,
formatCatalogLabName,
getModelCatalog,
type ModelCatalog,
type ModelCatalogCost,
type ModelCatalogEntry,
} from "../model-catalog"
@@ -174,7 +175,12 @@ export default function StatsModel() {
<Show when={catalogEntry() || stats() !== undefined} fallback={<ModelLoading />}>
<Show when={catalogEntry() || stats()} fallback={<ModelNotFound lab={labParam()} model={modelParam()} />}>
<>
<ModelHero data={stats() ?? null} catalog={catalogEntry() ?? null} labName={labName()} />
<ModelHero
data={stats() ?? null}
catalog={catalogEntry() ?? null}
catalogData={catalog() ?? null}
labName={labName()}
/>
<ModelOverview data={stats() ?? null} />
<ModelUsageSection data={stats()?.usage ?? []} />
<ModelUsersSection data={stats()?.usage ?? []} />
@@ -249,116 +255,171 @@ function ModelNotFound(props: { lab: string; model: string }) {
)
}
function ModelHero(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null; labName: string }) {
function ModelHero(props: {
data: StatsModelData | null
catalog: ModelCatalogEntry | null
catalogData: ModelCatalog | null
labName: string
}) {
const i18n = useI18n()
const language = useLanguage()
const labId = () => props.catalog?.lab ?? props.data?.provider ?? props.labName
const modelId = () => props.catalog?.id ?? props.data?.model ?? i18n.t("model.fallback")
const modelName = () => props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback")
const weights = () => props.catalog?.weights[0]
const labs = () => props.catalogData?.labs ?? []
const labModels = () =>
props.catalogData?.labs.find((lab) => lab.id === providerSlug(labId()))?.models ??
(props.catalog ? [props.catalog] : [])
return (
<section id="overview" data-section="model-hero">
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
{i18n.t("footer.modelData")}
</a>
<div data-slot="model-hero-grid">
<div data-slot="model-hero-copy">
<div data-slot="model-hero-tags">
<a data-slot="hero-meta" href={language.route(`${import.meta.env.BASE_URL}${providerSlug(labId())}`)}>
<ProviderIcon aria-hidden="true" id={getProviderIconId(labId())} />
<nav data-component="model-hero-breadcrumb" aria-label="Data breadcrumb">
<a data-slot="model-hero-crumb" href={language.route(import.meta.env.BASE_URL)}>
Data
</a>
<span data-slot="model-hero-separator">/</span>
<Show
when={labs().length > 0}
fallback={
<span data-slot="model-hero-crumb" data-current="true">
<span>{props.labName}</span>
</a>
<span data-slot="model-id-tag">{modelId()}</span>
</div>
<h1>
<a data-slot="heading-link" href="#overview">
{props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback")}
</a>
</h1>
<Show when={props.data} fallback={<p>{i18n.t("model.catalogFallback")}</p>}>
{(data) => (
<p>
{data().rank === null ? i18n.t("model.unranked") : i18n.t("model.ranked", { rank: data().rank ?? "" })}{" "}
{i18n.t("model.observedVolume", { share: formatPercent(data().tokenShare) })}
</p>
)}
</Show>
<ChevronDownIcon />
</span>
}
>
<details data-component="model-hero-menu">
<summary data-slot="model-hero-crumb" data-current="true">
<span>{props.labName}</span>
<ChevronDownIcon />
</summary>
<div data-slot="model-hero-options">
<For each={labs()}>
{(lab) => (
<a
data-slot="model-hero-option"
data-current={lab.id === providerSlug(labId()) ? "true" : undefined}
href={language.route(`${import.meta.env.BASE_URL}${lab.id}`)}
>
{lab.name}
</a>
)}
</For>
</div>
</details>
</Show>
<span data-slot="model-hero-separator">/</span>
<Show
when={labModels().length > 0}
fallback={
<span data-slot="model-hero-crumb" data-current="true" aria-current="page">
<span>{modelName()}</span>
<ChevronDownIcon />
</span>
}
>
<details data-component="model-hero-menu">
<summary data-slot="model-hero-crumb" data-current="true" aria-current="page">
<span>{modelName()}</span>
<ChevronDownIcon />
</summary>
<div data-slot="model-hero-options">
<For each={labModels()}>
{(model) => (
<a
data-slot="model-hero-option"
data-current={model.id === props.catalog?.id ? "true" : undefined}
href={language.route(`${import.meta.env.BASE_URL}${model.id}`)}
>
{model.name}
</a>
)}
</For>
</div>
</details>
</Show>
</nav>
<div data-slot="model-hero-title-row">
<span data-slot="model-hero-avatar">
<ProviderIcon aria-hidden="true" id={getProviderIconId(labId())} />
</span>
<h1>{modelName()}</h1>
<div data-slot="model-hero-actions">
<Show when={props.catalog?.openWeights && weights()}>
{(weight) => (
<a data-slot="model-weight-link" href={weight().url} target="_blank" rel="noopener noreferrer">
{i18n.t("model.weights", { label: weight().label })}
<a data-slot="model-hero-action" href={weight().url} target="_blank" rel="noopener noreferrer">
<ModelHeroActionIcon kind="weights" />
<span>Model weights</span>
</a>
)}
</Show>
</div>
<Show when={props.data} fallback={<ModelCatalogCallout catalog={props.catalog} />}>
{(data) => (
<div data-component="model-rank-panel">
<span>{i18n.t("model.rank")}</span>
<strong>{data().rank === null ? "—" : `#${data().rank}`}</strong>
<p>{formatModelRankMoveLabel(data(), i18n)}</p>
</div>
)}
</Show>
</div>
<div data-slot="model-hero-pattern" aria-hidden="true" />
<Show when={props.catalog}>{(catalog) => <ModelCatalogPanel data={catalog()} />}</Show>
<Show
when={props.data}
fallback={
<p data-slot="model-hero-state">
<span>Listed</span>
<span>across the shared model catalog.</span>
</p>
}
>
{(data) => (
<p data-slot="model-hero-rankline">
<span>Ranked</span>
<span data-slot="model-hero-rank-group">
<span data-slot="model-hero-pill">{formatHeroRank(data().rank)}</span>
<ModelHeroSparkline data={data()} />
</span>
<span>across last week's</span>
<span data-slot="model-hero-pill">OpenCode Go</span>
<span>usage with</span>
<span data-slot="model-hero-pill">{formatPercent(data().tokenShare)}</span>
<span>of observed</span>
<span data-slot="model-hero-pill">2M</span>
<span>volume.</span>
</p>
)}
</Show>
</section>
)
}
function ModelCatalogCallout(props: { catalog: ModelCatalogEntry | null }) {
const i18n = useI18n()
const language = useLanguage()
function ModelHeroActionIcon(props: { kind: "weights" | "compare" }) {
if (props.kind === "weights")
return (
<svg data-slot="model-hero-action-icon" viewBox="0 0 16 16" aria-hidden="true" fill="none">
<path d="M5.5 4.5H4.5V11.5H11.5V10.5" stroke="currentColor" stroke-linecap="square" />
<path d="M8.5 4.5H11.5V7.5" stroke="currentColor" stroke-linecap="square" />
<path d="M11.25 4.75L7.25 8.75" stroke="currentColor" stroke-linecap="square" />
</svg>
)
return (
<div data-component="model-rank-panel">
<span>{i18n.t("model.profile")}</span>
<strong>
{props.catalog?.releaseDate
? formatCatalogDate(props.catalog.releaseDate, language.tag(language.locale()), i18n.t("home.unknown"))
: i18n.t("model.listed")}
</strong>
<p>{i18n.t("model.noCurrentUsage")}</p>
</div>
<svg data-slot="model-hero-action-icon" viewBox="0 0 16 16" aria-hidden="true" fill="none">
<rect x="3.5" y="3.5" width="3" height="3" stroke="currentColor" />
<rect x="9.5" y="3.5" width="3" height="3" stroke="currentColor" />
<rect x="3.5" y="9.5" width="3" height="3" stroke="currentColor" />
<rect x="9.5" y="9.5" width="3" height="3" stroke="currentColor" />
</svg>
)
}
function ModelCatalogPanel(props: { data: ModelCatalogEntry }) {
const i18n = useI18n()
const language = useLanguage()
function ModelHeroSparkline(props: { data: StatsModelData }) {
const values = () => props.data.usage.slice(-14).map((point) => point.tokens)
return (
<aside data-component="model-catalog" aria-label={i18n.t("model.facts")}>
<div data-slot="model-catalog-grid">
<CatalogDatum
label={i18n.t("model.context")}
value={formatCatalogLimit(props.data.limit?.context, i18n.t("home.unknown"))}
/>
<CatalogDatum
label={i18n.t("model.output")}
value={formatCatalogLimit(props.data.limit?.output, i18n.t("home.unknown"))}
/>
<CatalogDatum
label={i18n.t("model.knowledge")}
value={formatCatalogDate(props.data.knowledge, language.tag(language.locale()), i18n.t("home.unknown"))}
/>
<CatalogDatum
label={i18n.t("model.release")}
value={formatCatalogDate(props.data.releaseDate, language.tag(language.locale()), i18n.t("home.unknown"))}
/>
<CatalogDatum
label={i18n.t("model.inputs")}
value={formatCatalogModalities(props.data.modalities.input, i18n)}
/>
</div>
</aside>
<span data-slot="model-hero-sparkline" aria-hidden="true">
<svg viewBox="0 0 36 24" fill="none">
<path d={sparklineAreaPath(values())} fill="currentColor" opacity="0.14" />
<path d={sparklineLinePath(values())} stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
</svg>
</span>
)
}
function CatalogDatum(props: { label: string; value: string }) {
function ChevronDownIcon() {
return (
<article data-component="model-catalog-datum">
<span>{props.label}</span>
<strong>{props.value}</strong>
</article>
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" fill="none">
<path d="M4.75 6.25L8 9.5L11.25 6.25" stroke="currentColor" stroke-width="1.5" />
</svg>
)
}
@@ -938,6 +999,40 @@ function formatRankMove(change: number) {
return `${change}`
}
function formatHeroRank(rank: number | null) {
if (rank === null) return "--"
return String(rank).padStart(2, "0")
}
function sparklineLinePath(values: number[]) {
return sparklinePoints(values)
.map(
(point, index) => `${index === 0 ? "M" : "L"}${formatSparklinePoint(point.x)} ${formatSparklinePoint(point.y)}`,
)
.join(" ")
}
function sparklineAreaPath(values: number[]) {
const points = sparklinePoints(values)
return `M${formatSparklinePoint(points[0].x)} 18 ${points
.map((point) => `L${formatSparklinePoint(point.x)} ${formatSparklinePoint(point.y)}`)
.join(" ")} L${formatSparklinePoint(points[points.length - 1].x)} 18 Z`
}
function sparklinePoints(values: number[]) {
const normalized = values.length > 1 ? values : [values[0] ?? 0, values[0] ?? 0]
const min = Math.min(...normalized)
const max = Math.max(...normalized)
return normalized.map((value, index) => ({
x: 8 + (index / Math.max(1, normalized.length - 1)) * 20,
y: min === max ? 12 : 18 - ((value - min) / (max - min)) * 12,
}))
}
function formatSparklinePoint(value: number) {
return Number(value.toFixed(2)).toString()
}
function formatModelRankMoveLabel(data: StatsModelData, i18n: ReturnType<typeof useI18n>) {
if (data.rank === null) return i18n.t("model.noUsageLastWeek")
if (data.previousRank === null) return i18n.t("model.newThisWeek")
@@ -993,35 +1088,6 @@ function formatChange(value: number) {
return `${value}%`
}
function formatCatalogLimit(value: number | undefined, unknown: string) {
return value === undefined ? unknown : formatTokens(value)
}
function formatCatalogModalities(value: string[], i18n: ReturnType<typeof useI18n>) {
if (value.length === 0) return i18n.t("home.unknown")
return value.map((item) => formatCatalogModality(item, i18n)).join(", ")
}
function formatCatalogModality(value: string, i18n: ReturnType<typeof useI18n>) {
if (value === "pdf") return i18n.t("model.pdf")
return value.charAt(0).toUpperCase() + value.slice(1)
}
function formatCatalogDate(value: string | undefined, locale: string, unknown: string) {
if (!value) return unknown
const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
if (!match) return value
const year = Number(match[1])
const month = match[2] ? Number(match[2]) - 1 : 0
const day = match[3] ? Number(match[3]) : 1
return new Intl.DateTimeFormat(locale, {
month: match[2] ? "short" : undefined,
day: match[3] ? "numeric" : undefined,
year: "numeric",
timeZone: "UTC",
}).format(new Date(Date.UTC(year, month, day)))
}
function trimNumber(value: number, digits: number) {
return Number(value.toFixed(digits)).toLocaleString("en")
}
+14 -9
View File
@@ -559,24 +559,28 @@ function LabModelRow(props: {
}) {
const i18n = useI18n()
const language = useLanguage()
const showTooltip = (x: number, y: number) => {
const showTooltip = (target: HTMLAnchorElement) => {
const rect = target.getBoundingClientRect()
const viewportWidth = typeof window === "undefined" ? 0 : window.innerWidth
const viewportHeight = typeof window === "undefined" ? 0 : window.innerHeight
const anchorX = viewportWidth > 0 ? Math.min(Math.max(rect.left + 320, 24), viewportWidth - 24) : rect.left + 320
props.onTooltipChange({
model: props.model,
placement: viewportWidth > 0 && x > viewportWidth - 280 ? "left" : "right",
placement: viewportWidth > 0 && anchorX > viewportWidth - 280 ? "left" : "right",
usage: props.usage,
x,
y: viewportHeight > 0 ? Math.min(Math.max(y, 96), viewportHeight - 128) : y,
x: anchorX,
y:
viewportHeight > 0
? Math.min(Math.max(rect.top + rect.height / 2, 96), viewportHeight - 128)
: rect.top + rect.height / 2,
})
}
const showPointerTooltip: JSX.EventHandler<HTMLAnchorElement, PointerEvent> = (event) => {
if (event.pointerType === "touch") return
showTooltip(event.clientX, event.clientY)
showTooltip(event.currentTarget)
}
const showFocusTooltip: JSX.EventHandler<HTMLAnchorElement, FocusEvent> = (event) => {
const rect = event.currentTarget.getBoundingClientRect()
showTooltip(rect.left + rect.width * 0.58, rect.top + rect.height / 2)
showTooltip(event.currentTarget)
}
return (
<a
@@ -587,11 +591,12 @@ function LabModelRow(props: {
onBlur={() => props.onTooltipChange(undefined)}
onFocus={showFocusTooltip}
onPointerEnter={showPointerTooltip}
onPointerDown={() => props.onTooltipChange(undefined)}
onPointerLeave={(event) => {
if (event.pointerType === "touch") return
props.onTooltipChange(undefined)
}}
onPointerMove={showPointerTooltip}
onClick={() => props.onTooltipChange(undefined)}
>
<span data-slot="lab-model-cell" data-column="model" role="cell">
<span data-slot="lab-model-avatar" aria-hidden="true">
@@ -851,7 +856,7 @@ function trimNumber(value: number, digits: number) {
function usageStripHeight(value: number, max: number) {
if (value <= 0 || max <= 0) return 0
return Math.max(1, (value / max) * 40)
return Math.max(2, (value / max) * 76)
}
function usageLineY(value: number, max: number) {
+349 -17
View File
@@ -2870,7 +2870,7 @@
padding: 0 8px;
overflow: hidden;
background: var(--stats-layer-2);
color: var(--stats-hero-muted);
color: var(--stats-faint);
font-size: 13px;
font-weight: 500;
line-height: 1.1;
@@ -2878,6 +2878,10 @@
white-space: nowrap;
}
[data-page="stats"] a[data-slot="lab-hero-crumb"] {
color: var(--stats-faint);
}
[data-page="stats"] [data-component="lab-hero-menu"] {
position: relative;
display: inline-block;
@@ -2894,6 +2898,7 @@
[data-page="stats"] [data-slot="lab-hero-crumb"][data-current="true"] {
padding-right: 4px;
color: var(--stats-text);
}
[data-page="stats"] a[data-slot="lab-hero-crumb"]:hover,
@@ -2915,7 +2920,7 @@
flex: 0 0 auto;
width: 16px;
height: 16px;
color: var(--stats-faint);
color: currentColor;
}
[data-page="stats"] [data-component="lab-hero-menu"][open] [data-slot="lab-hero-crumb"] svg {
@@ -3048,6 +3053,288 @@
line-height: 1.5;
}
[data-page="stats"] [data-section="model-hero"] {
align-content: start;
gap: 24px;
min-height: 372px;
padding: 128px 0 40px;
overflow: visible;
box-shadow: none;
}
@media (max-width: 80rem) and (min-width: 74.001rem) {
[data-page="stats"] [data-section="model-hero"] {
padding-right: 40px;
padding-left: 40px;
}
}
[data-page="stats"] [data-component="model-hero-breadcrumb"] {
display: flex;
align-items: center;
gap: 8px;
height: 24px;
min-width: 0;
}
[data-page="stats"] [data-slot="model-hero-crumb"] {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: min(100%, 280px);
height: 24px;
box-sizing: border-box;
padding: 0 8px;
overflow: hidden;
background: var(--stats-layer-2);
color: var(--stats-hero-muted);
font-size: 13px;
font-weight: 500;
line-height: 1.1;
text-decoration: none;
white-space: nowrap;
}
[data-page="stats"] [data-slot="model-hero-crumb"][data-current="true"] {
padding-right: 4px;
}
[data-page="stats"] [data-component="model-hero-menu"] {
position: relative;
display: inline-block;
min-width: 0;
}
[data-page="stats"] [data-component="model-hero-menu"] summary {
cursor: pointer;
list-style: none;
}
[data-page="stats"] [data-component="model-hero-menu"] summary::-webkit-details-marker {
display: none;
}
[data-page="stats"] a[data-slot="model-hero-crumb"]:hover,
[data-page="stats"] a[data-slot="model-hero-crumb"]:focus-visible,
[data-page="stats"] summary[data-slot="model-hero-crumb"]:hover,
[data-page="stats"] summary[data-slot="model-hero-crumb"]:focus-visible {
color: var(--stats-text);
outline: none;
text-decoration: none;
}
[data-page="stats"] [data-slot="model-hero-crumb"] span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
[data-page="stats"] [data-slot="model-hero-crumb"] svg {
flex: 0 0 auto;
width: 16px;
height: 16px;
color: var(--stats-faint);
}
[data-page="stats"] [data-component="model-hero-menu"][open] [data-slot="model-hero-crumb"] svg {
transform: rotate(180deg);
}
[data-page="stats"] [data-slot="model-hero-options"] {
position: absolute;
top: calc(100% + 8px);
left: 50%;
z-index: 5;
display: grid;
width: max-content;
min-width: 180px;
max-width: min(360px, calc(100vw - 48px));
max-height: min(360px, calc(100vh - 160px));
padding: 6px;
overflow: auto;
border: 1px solid var(--stats-line);
background: var(--stats-bg);
box-shadow: 0 12px 32px #0000001a;
transform: translateX(-50%);
}
[data-page="stats"] [data-slot="model-hero-option"] {
display: flex;
align-items: center;
min-width: 0;
height: 30px;
padding: 0 8px;
overflow: hidden;
color: var(--stats-muted);
font-size: 13px;
font-weight: 500;
line-height: 1.2;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] [data-slot="model-hero-option"]:hover,
[data-page="stats"] [data-slot="model-hero-option"]:focus-visible {
background: var(--stats-layer-2);
color: var(--stats-text);
outline: none;
text-decoration: none;
}
[data-page="stats"] [data-slot="model-hero-option"][data-current="true"] {
background: var(--stats-layer-2);
color: var(--stats-text);
}
[data-page="stats"] [data-slot="model-hero-separator"] {
display: inline-flex;
align-items: center;
justify-content: center;
width: 8px;
height: 24px;
color: var(--stats-faint);
font-size: 13px;
font-weight: 500;
line-height: 1.1;
}
[data-page="stats"] [data-slot="model-hero-title-row"] {
display: flex;
align-items: center;
gap: 24px;
min-width: 0;
min-height: 60px;
}
[data-page="stats"] [data-slot="model-hero-avatar"] {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 40px;
height: 40px;
background: var(--stats-accent-text);
box-shadow: inset 0 0 0 1px #0000001a;
color: #ffffff;
}
[data-page="stats"] [data-slot="model-hero-avatar"] svg {
width: 28px;
height: 28px;
}
[data-page="stats"] [data-section="model-hero"] h1 {
flex: 1 1 auto;
min-width: 0;
max-width: none;
overflow: hidden;
color: var(--stats-text);
font-size: 40px;
font-weight: 500;
line-height: 60px;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-page="stats"] [data-slot="model-hero-actions"] {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
margin-left: auto;
}
[data-page="stats"] [data-slot="model-hero-action"] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 32px;
box-sizing: border-box;
padding: 0 12px;
background: var(--stats-bg);
color: var(--stats-text);
font-size: 13px;
font-weight: 500;
line-height: 1;
box-shadow:
0 0 0 0 #00000024,
0 0 0 0.5px #00000024,
0 1px 1.5px 0 #0000001a;
text-decoration: none;
white-space: nowrap;
}
[data-page="stats"] [data-slot="model-hero-action"]:hover,
[data-page="stats"] [data-slot="model-hero-action"]:focus-visible {
background: var(--stats-layer-2);
outline: none;
text-decoration: none;
}
[data-page="stats"] [data-slot="model-hero-action-icon"] {
flex: 0 0 auto;
width: 16px;
height: 16px;
color: currentColor;
}
[data-page="stats"] [data-section="model-hero"] [data-slot="model-hero-pattern"] {
width: 100%;
height: 16px;
overflow: hidden;
background: color-mix(in srgb, var(--stats-text) 10%, transparent);
mask-position: center top;
-webkit-mask-position: center top;
}
[data-page="stats"] [data-slot="model-hero-rankline"],
[data-page="stats"] [data-slot="model-hero-state"] {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 24px;
margin: 0;
color: var(--stats-muted);
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
[data-page="stats"] [data-slot="model-hero-pill"],
[data-page="stats"] [data-slot="model-hero-sparkline"] {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
box-sizing: border-box;
padding: 0 8px;
background: var(--stats-layer-2);
color: var(--stats-hero-muted);
font-size: 13px;
font-weight: 700;
line-height: 1.1;
}
[data-page="stats"] [data-slot="model-hero-rank-group"] {
display: inline-flex;
align-items: center;
gap: 2px;
}
[data-page="stats"] [data-slot="model-hero-sparkline"] {
width: 36px;
padding: 0;
color: #198b43;
}
[data-page="stats"] [data-slot="model-hero-sparkline"] svg {
width: 36px;
height: 24px;
}
[data-page="stats"] [data-section="lab-overview"] {
position: relative;
box-sizing: border-box;
@@ -3117,7 +3404,7 @@
box-sizing: border-box;
min-width: 0;
min-height: 192px;
padding: 40px 60px;
padding: 40px;
position: relative;
}
@@ -3147,9 +3434,9 @@
min-width: 0;
overflow: hidden;
color: var(--stats-text);
font-size: 76px;
font-size: clamp(36px, 5.4vw, 76px);
font-weight: 500;
line-height: 88px;
line-height: 1.15;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
@@ -4070,8 +4357,8 @@
[data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] {
--lab-usage-gap: 4px;
--lab-usage-column-gutter: calc(var(--lab-usage-gap) / 2);
--lab-usage-bar-height: 40px;
--lab-usage-line-bottom: 80px;
--lab-usage-bar-height: 76px;
--lab-usage-line-bottom: 124px;
--lab-usage-bar: #dbdbdb;
--lab-usage-line: #808080;
--lab-usage-active: #3b5cf6;
@@ -4688,6 +4975,11 @@
padding: 104px 32px 40px;
}
[data-page="stats"] [data-section="model-hero"] {
min-height: 340px;
padding: 104px 32px 40px;
}
[data-page="stats"] [data-slot="lab-overview-copy"] {
padding-right: 32px;
padding-left: 32px;
@@ -4748,8 +5040,7 @@
[data-page="stats"] [data-component="lab-overview-metric"] {
min-height: 144px;
padding-top: 28px;
padding-bottom: 28px;
padding: 28px 40px;
border-top: 1px solid var(--stats-line);
}
@@ -4917,10 +5208,36 @@
[data-page="stats"] [data-section="model-hero"] {
gap: 20px;
min-height: 316px;
padding: 72px 24px 40px;
}
[data-page="stats"] [data-slot="model-hero-grid"] {
gap: 24px;
[data-page="stats"] [data-component="model-hero-breadcrumb"] {
flex-wrap: wrap;
height: auto;
}
[data-page="stats"] [data-slot="model-hero-crumb"] {
max-width: min(100%, 240px);
}
[data-page="stats"] [data-slot="model-hero-title-row"] {
flex-wrap: wrap;
gap: 16px;
min-height: 0;
}
[data-page="stats"] [data-slot="model-hero-avatar"] {
display: none;
}
[data-page="stats"] [data-slot="model-hero-actions"] {
flex: 1 1 100%;
margin-left: 0;
}
[data-page="stats"] [data-slot="model-hero-action"] {
flex: 0 0 auto;
}
[data-page="stats"] [data-section="lab-hero"] {
@@ -4930,7 +5247,7 @@
}
[data-page="stats"] [data-section="lab-overview"] {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
[data-page="stats"] [data-slot="lab-overview-copy"] {
@@ -4958,12 +5275,16 @@
}
[data-page="stats"] [data-component="lab-overview-metric"]::before {
display: block;
}
[data-page="stats"] [data-component="lab-overview-metric"]:nth-of-type(1)::before {
display: none;
}
[data-page="stats"] [data-component="lab-overview-metric"] strong {
font-size: 56px;
line-height: 64px;
font-size: clamp(36px, 10vw, 52px);
line-height: 1.08;
}
[data-page="stats"] [data-slot="lab-hero-title-row"] {
@@ -5012,12 +5333,22 @@
}
[data-page="stats"] [data-section="model-hero"] h1 {
flex-basis: 100%;
font-size: 38px;
line-height: 1;
line-height: 44px;
overflow-wrap: anywhere;
white-space: normal;
}
[data-page="stats"] [data-slot="model-hero-pattern"] {
height: 14px;
height: 16px;
}
[data-page="stats"] [data-slot="model-hero-rankline"],
[data-page="stats"] [data-slot="model-hero-state"] {
gap: 6px;
font-size: 14px;
line-height: 22px;
}
[data-page="stats"] [data-slot="model-catalog-grid"] {
@@ -5125,7 +5456,8 @@
--model-usage-mobile-track-width: calc(
var(--model-usage-count) * var(--model-usage-mobile-bar-width) + var(--model-usage-mobile-edge-space)
);
--lab-usage-line-bottom: 64px;
--lab-usage-bar-height: 64px;
--lab-usage-line-bottom: 104px;
grid-template-rows: minmax(0, 360px) 14px;
gap: 24px;
height: 398px;
+11
View File
@@ -42,6 +42,7 @@ import { DialogModel } from "./component/dialog-model"
import { useConnected } from "./component/use-connected"
import { DialogMcp } from "./component/dialog-mcp"
import { DialogStatus } from "./component/dialog-status"
import { DialogDebug } from "./component/dialog-debug"
import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
@@ -115,6 +116,7 @@ const appBindingCommands = [
"provider.connect",
"console.org.switch",
"opencode.status",
"opencode.debug",
"theme.switch",
"theme.switch_mode",
"theme.mode.lock",
@@ -764,6 +766,15 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
},
category: "System",
},
{
name: "opencode.debug",
title: "View debug info",
slashName: "debug",
run: () => {
dialog.replace(() => <DialogDebug />)
},
category: "System",
},
{
name: "theme.switch",
title: "Switch theme",
@@ -0,0 +1,90 @@
import { TextAttributes } from "@opentui/core"
import { createMemo, createSignal, For } from "solid-js"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useRoute } from "../context/route"
import { useLocal } from "../context/local"
import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast"
import { useBindings } from "../keymap"
import { describeOS, describeTerminal } from "../util/system"
export function DialogDebug() {
const { theme } = useTheme()
const dialog = useDialog()
const route = useRoute()
const local = useLocal()
const clipboard = useClipboard()
const toast = useToast()
const [copied, setCopied] = createSignal(false)
dialog.setSize("large")
const entries = createMemo(() => {
const model = local.model.current()
return [
{ label: "Version", value: `${InstallationVersion} (${InstallationChannel})` },
{ label: "Date", value: new Date().toISOString() },
{ label: "OS", value: describeOS() },
{ label: "Terminal", value: describeTerminal() },
{ label: "Session ID", value: route.data.type === "session" ? route.data.sessionID : "n/a" },
{ label: "Model", value: model ? `${model.providerID}/${model.modelID}` : "n/a" },
]
})
const copy = () => {
const text = entries()
.map((entry) => `${entry.label}: ${entry.value}`)
.join("\n")
void clipboard
.write?.(text)
.then(() => {
setCopied(true)
toast.show({ message: "Debug info copied to clipboard", variant: "info" })
})
.catch(toast.error)
}
useBindings(() => ({
bindings: [{ key: "return", desc: "Copy debug info", group: "Dialog", cmd: copy }],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Debug
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
{/* No click-to-copy here: releasing a mouse selection must trigger the
global copy-on-select so users can copy a single value, e.g. the session id. */}
<box>
<For each={entries()}>
{(entry) => (
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={theme.textMuted}>
{entry.label.padEnd(10)}
</text>
<text fg={theme.text} wrapMode="word">
{entry.value}
</text>
</box>
)}
</For>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Share this when reporting an issue.</text>
<text onMouseUp={copy}>
<span style={{ fg: copied() ? theme.success : theme.text }}>
<b>{copied() ? "✓ copied" : "copy"}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>enter</span>
</text>
</box>
</box>
)
}
+1 -20
View File
@@ -1,4 +1,3 @@
import { release } from "node:os"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createSignal, For, Show } from "solid-js"
@@ -6,6 +5,7 @@ import { getScrollAcceleration } from "../util/scroll"
import { useClipboard } from "../context/clipboard"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { useExit } from "../context/exit"
import { describeOS, describeTerminal } from "../util/system"
export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
const term = useTerminalDimensions()
@@ -238,22 +238,3 @@ function buildIssueURL(message: string, stack: string) {
setBody(stack.slice(0, lo) + marker)
return url
}
function describeOS() {
const name =
process.platform === "darwin"
? "macOS"
: process.platform === "win32"
? "Windows"
: process.platform === "linux"
? "Linux"
: process.platform
return `${name} ${release()} (${process.arch})`
}
function describeTerminal() {
const program = process.env.TERM_PROGRAM || process.env.TERM || "unknown"
const version = process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""
const multiplexer = process.env.TMUX ? " in tmux" : process.env.STY ? " in screen" : ""
return `${program}${version}${multiplexer}`
}
+2
View File
@@ -81,6 +81,7 @@ export const Definitions = {
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
status_view: keybind("<leader>s", "View status"),
debug_view: keybind("none", "View debug info"),
session_export: keybind("<leader>x", "Export session to editor"),
session_copy: keybind("none", "Copy session transcript"),
@@ -288,6 +289,7 @@ export const CommandMap = {
sidebar_toggle: "session.sidebar.toggle",
scrollbar_toggle: "session.toggle.scrollbar",
status_view: "opencode.status",
debug_view: "opencode.debug",
session_export: "session.export",
session_copy: "session.copy",
session_move: "session.move",
+3
View File
@@ -49,6 +49,9 @@ export function Dialog(
>
<box
onMouseUp={(e: { stopPropagation(): void }) => {
// A selection release must bubble up to the copy-on-select handler in
// DialogProvider; the backdrop's dismiss flag keeps it from closing the dialog.
if (renderer.getSelection()?.getSelectedText()) return
dismiss = false
e.stopPropagation()
}}
+20
View File
@@ -0,0 +1,20 @@
import { release } from "node:os"
export function describeOS() {
const name =
process.platform === "darwin"
? "macOS"
: process.platform === "win32"
? "Windows"
: process.platform === "linux"
? "Linux"
: process.platform
return `${name} ${release()} (${process.arch})`
}
export function describeTerminal() {
const program = process.env.TERM_PROGRAM || process.env.TERM || "unknown"
const version = process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""
const multiplexer = process.env.TMUX ? " in tmux" : process.env.STY ? " in screen" : ""
return `${program}${version}${multiplexer}`
}