Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b684cc8746 | |||
| 5455fed78e | |||
| 6dba0cc498 | |||
| c07ac0db2f | |||
| 20b090e493 | |||
| cb54824f2f | |||
| 3adfb970bf | |||
| 5ecd19db9f | |||
| 299daa2815 | |||
| 7457139849 | |||
| 373cd08b98 |
@@ -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" })
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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}`
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user