From cdf041362b418a4047b995b971dc3cd2ca6bc05c Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:05:45 +0100 Subject: [PATCH 1/9] feat(web): add @tanstack/table-core and @tanstack/virtual-core --- bun.lock | 18 ++++++++---------- packages/web/package.json | 2 ++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 57d0877f3..0eb3e8e28 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,8 @@ "packages/web": { "name": "@models.dev/web", "dependencies": { + "@tanstack/table-core": "^8", + "@tanstack/virtual-core": "^3", "hono": "^4.8.0", "models.dev": "workspace:*", }, @@ -56,9 +58,13 @@ "@models.dev/web": ["@models.dev/web@workspace:packages/web"], + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.19", "", {}, "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g=="], + "@tsconfig/bun": ["@tsconfig/bun@1.0.8", "", {}, "sha512-JlJaRaS4hBTypxtFe8WhnwV8blf0R+3yehLk8XuyxUYNx6VXsKCjACSCvOYEFUiqlhlBWxtYCn/zRlOb8BzBQg=="], - "@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="], + "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], "@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], @@ -78,7 +84,7 @@ "buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="], - "bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="], + "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -322,8 +328,6 @@ "http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], - "models.dev/@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], - "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], "opencontrol/hono": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="], @@ -331,11 +335,5 @@ "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], "bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], - - "models.dev/@types/bun/bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], - - "models.dev/@types/bun/bun-types/@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="], - - "models.dev/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], } } diff --git a/packages/web/package.json b/packages/web/package.json index 852f51abe..691046d69 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -6,6 +6,8 @@ "build": "./script/build.ts" }, "dependencies": { + "@tanstack/table-core": "^8", + "@tanstack/virtual-core": "^3", "hono": "^4.8.0", "models.dev": "workspace:*" }, From aa07bd8e61b4abbb6285d2affce0e5c0e22ab307 Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:06:14 +0100 Subject: [PATCH 2/9] feat(web): add Row type and flattenProviders utility --- packages/web/src/data.test.ts | 67 +++++++++++++++++++++++++++++++++++ packages/web/src/data.ts | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 packages/web/src/data.test.ts create mode 100644 packages/web/src/data.ts diff --git a/packages/web/src/data.test.ts b/packages/web/src/data.test.ts new file mode 100644 index 000000000..730a52ebd --- /dev/null +++ b/packages/web/src/data.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "bun:test"; +import { flattenProviders } from "./data"; + +const mockApi = { + anthropic: { + name: "Anthropic", + models: { + "claude-3-5-haiku-20241022": { + id: "claude-3-5-haiku-20241022", + name: "Claude 3.5 Haiku", + family: "claude-haiku", + reasoning: false, + tool_call: true, + attachment: true, + temperature: true, + open_weights: false, + modalities: { input: ["text", "image"], output: ["text"] }, + cost: { input: 0.8, output: 4.0 }, + limit: { context: 200_000, output: 8_192 }, + release_date: "2024-11-04", + last_updated: "2024-11-04", + }, + }, + }, +}; + +describe("flattenProviders", () => { + it("creates one row per model", () => { + const rows = flattenProviders(mockApi as any); + expect(rows).toHaveLength(1); + }); + + it("adds providerId and providerName to each row", () => { + const [row] = flattenProviders(mockApi as any); + expect(row.providerId).toBe("anthropic"); + expect(row.providerName).toBe("Anthropic"); + }); + + it("adds modelId to each row", () => { + const [row] = flattenProviders(mockApi as any); + expect(row.modelId).toBe("claude-3-5-haiku-20241022"); + }); + + it("preserves model fields", () => { + const [row] = flattenProviders(mockApi as any); + expect(row.name).toBe("Claude 3.5 Haiku"); + expect(row.cost?.input).toBe(0.8); + expect(row.limit.context).toBe(200_000); + }); + + it("filters out alpha models", () => { + const apiWithAlpha = { + ...mockApi, + test: { + name: "Test", + models: { + "alpha-model": { + ...mockApi.anthropic.models["claude-3-5-haiku-20241022"], + status: "alpha", + }, + }, + }, + }; + const rows = flattenProviders(apiWithAlpha as any); + expect(rows).toHaveLength(1); // alpha filtered out + }); +}); diff --git a/packages/web/src/data.ts b/packages/web/src/data.ts new file mode 100644 index 000000000..f21822aa0 --- /dev/null +++ b/packages/web/src/data.ts @@ -0,0 +1,64 @@ +export type Row = { + providerId: string; + providerName: string; + modelId: string; + name: string; + family?: string; + reasoning: boolean; + tool_call: boolean; + attachment: boolean; + temperature?: boolean; + structured_output?: boolean; + open_weights: boolean; + modalities: { input: string[]; output: string[] }; + cost?: { + input?: number; + output?: number; + reasoning?: number; + cache_read?: number; + cache_write?: number; + input_audio?: number; + output_audio?: number; + }; + limit: { context: number; input?: number; output: number }; + knowledge?: string; + release_date?: string; + last_updated?: string; +}; + +type ApiJson = Record< + string, + { + name: string; + models: Record; + } +>; + +export function flattenProviders(api: ApiJson): Row[] { + const rows: Row[] = []; + for (const [providerId, provider] of Object.entries(api)) { + for (const [modelId, model] of Object.entries(provider.models)) { + if (model.status === "alpha") continue; + rows.push({ + providerId, + providerName: provider.name, + modelId, + name: model.name, + family: model.family, + reasoning: model.reasoning, + tool_call: model.tool_call, + attachment: model.attachment, + temperature: model.temperature, + structured_output: model.structured_output, + open_weights: model.open_weights, + modalities: model.modalities, + cost: model.cost, + limit: model.limit, + knowledge: model.knowledge, + release_date: model.release_date, + last_updated: model.last_updated, + }); + } + } + return rows; +} From 229bdbfc7bff53e3092d3c0ea3b44a3706a3c84c Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:06:44 +0100 Subject: [PATCH 3/9] feat(web): add URL state serialization utilities --- packages/web/src/url-state.test.ts | 60 ++++++++++++++++++++++++++++++ packages/web/src/url-state.ts | 48 ++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 packages/web/src/url-state.test.ts create mode 100644 packages/web/src/url-state.ts diff --git a/packages/web/src/url-state.test.ts b/packages/web/src/url-state.test.ts new file mode 100644 index 000000000..a6ac2e3e8 --- /dev/null +++ b/packages/web/src/url-state.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "bun:test"; +import { parseUrlState, serializeUrlState, ALL_COLUMN_IDS } from "./url-state"; + +describe("parseUrlState", () => { + it("returns empty defaults when no params", () => { + const state = parseUrlState(new URLSearchParams("")); + expect(state.search).toBe(""); + expect(state.sort).toBeNull(); + expect(state.order).toBe("asc"); + expect(state.cols).toEqual([...ALL_COLUMN_IDS]); + }); + + it("parses search param", () => { + const state = parseUrlState(new URLSearchParams("search=gpt")); + expect(state.search).toBe("gpt"); + }); + + it("parses sort and order", () => { + const state = parseUrlState(new URLSearchParams("sort=input-cost&order=desc")); + expect(state.sort).toBe("input-cost"); + expect(state.order).toBe("desc"); + }); + + it("parses cols param as array", () => { + const state = parseUrlState(new URLSearchParams("cols=provider,model,input-cost")); + expect(state.cols).toEqual(["provider", "model", "input-cost"]); + }); +}); + +describe("serializeUrlState", () => { + it("omits cols when all visible", () => { + const params = serializeUrlState({ + search: "", + sort: null, + order: "asc", + cols: [...ALL_COLUMN_IDS], + }); + expect(params.get("cols")).toBeNull(); + }); + + it("includes cols when not all visible", () => { + const params = serializeUrlState({ + search: "", + sort: null, + order: "asc", + cols: ["provider", "model"], + }); + expect(params.get("cols")).toBe("provider,model"); + }); + + it("omits search when empty", () => { + const params = serializeUrlState({ + search: "", + sort: null, + order: "asc", + cols: [...ALL_COLUMN_IDS], + }); + expect(params.get("search")).toBeNull(); + }); +}); diff --git a/packages/web/src/url-state.ts b/packages/web/src/url-state.ts new file mode 100644 index 000000000..93cff089a --- /dev/null +++ b/packages/web/src/url-state.ts @@ -0,0 +1,48 @@ +export const ALL_COLUMN_IDS = [ + "provider", "model", "family", "provider-id", "model-id", + "tool-call", "reasoning", "input-modalities", "output-modalities", + "input-cost", "output-cost", "reasoning-cost", "cache-read-cost", + "cache-write-cost", "audio-input-cost", "audio-output-cost", + "context-limit", "input-limit", "output-limit", + "structured-output", "temperature", "weights", + "knowledge", "release-date", "last-updated", +] as const; + +export type ColumnId = (typeof ALL_COLUMN_IDS)[number]; + +export type UrlState = { + search: string; + sort: string | null; + order: "asc" | "desc"; + cols: string[]; +}; + +export function parseUrlState(params: URLSearchParams): UrlState { + const colsParam = params.get("cols"); + const cols = colsParam + ? colsParam + .split(",") + .filter((c) => (ALL_COLUMN_IDS as readonly string[]).includes(c)) + : [...ALL_COLUMN_IDS]; + + return { + search: params.get("search") ?? "", + sort: params.get("sort"), + order: params.get("order") === "desc" ? "desc" : "asc", + cols: cols.length > 0 ? cols : [...ALL_COLUMN_IDS], + }; +} + +export function serializeUrlState(state: UrlState): URLSearchParams { + const params = new URLSearchParams(); + if (state.search) params.set("search", state.search); + if (state.sort) { + params.set("sort", state.sort); + if (state.order !== "asc") params.set("order", state.order); + } + const allVisible = + state.cols.length === ALL_COLUMN_IDS.length && + ALL_COLUMN_IDS.every((id) => state.cols.includes(id)); + if (!allVisible) params.set("cols", state.cols.join(",")); + return params; +} From 1918e49f38606460bffd8b1353dad32107d0f3e4 Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:10:09 +0100 Subject: [PATCH 4/9] =?UTF-8?q?feat(web):=20strip=20render.tsx=20to=20shel?= =?UTF-8?q?l=20=E2=80=94=20table=20rows=20moved=20to=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/web/src/render.tsx | 257 ++---------------------------------- 1 file changed, 13 insertions(+), 244 deletions(-) diff --git a/packages/web/src/render.tsx b/packages/web/src/render.tsx index a1bdfcc2f..a5b60b064 100644 --- a/packages/web/src/render.tsx +++ b/packages/web/src/render.tsx @@ -210,252 +210,20 @@ export const Rendered = renderToString( ⌘K +
+ + +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {Object.entries(Providers) - .sort(([, providerA], [, providerB]) => - providerA.name.localeCompare(providerB.name) - ) - .flatMap(([providerId, provider]) => - Object.entries(provider.models) - .filter(([, model]) => model.status !== "alpha") - .sort(([, modelA], [, modelB]) => - modelA.name.localeCompare(modelB.name) - ) - .map(([modelId, model]) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - )) - )} - -
- Provider - - Model - - Family - - Provider ID - - Model ID - - Tool Call - - Reasoning - - Input - - Output - -
- - Input Cost -
- per 1M tokens -
- -
-
-
- - Output Cost -
- per 1M tokens -
- -
-
-
- - Reasoning Cost -
- per 1M tokens -
- -
-
-
- - Cache Read Cost -
- per 1M tokens -
- -
-
-
- - Cache Write Cost -
- per 1M tokens -
- -
-
-
- - Audio Input Cost -
- per 1M tokens -
- -
-
-
- - Audio Output Cost -
- per 1M tokens -
- -
-
- Context Limit - - Input Limit - - Output Limit - - Structured Output - - Temperature - - Weights - - Knowledge - - Release Date - - Last Updated -
-
- {renderProviderLogo(providerId)} - {provider.name} -
-
{model.name}{model.family ?? "-"}{providerId} -
- {modelId} - -
-
{model.tool_call ? "Yes" : "No"}{model.reasoning ? "Yes" : "No"} -
- {model.modalities.input.map((modality) => - getModalityIcon(modality) - )} -
-
-
- {model.modalities.output.map((modality) => - getModalityIcon(modality) - )} -
-
{renderCost(model.cost?.input)}{renderCost(model.cost?.output)}{renderCost(model.cost?.reasoning)}{renderCost(model.cost?.cache_read)}{renderCost(model.cost?.cache_write)}{renderCost(model.cost?.input_audio)}{renderCost(model.cost?.output_audio)}{model.limit.context.toLocaleString()}{model.limit.input?.toLocaleString() ?? "-"}{model.limit.output.toLocaleString()} - {model.structured_output === undefined - ? "-" - : model.structured_output - ? "Yes" - : "No"} - {model.temperature ? "Yes" : "No"}{model.open_weights ? "Open" : "Closed"} - {model.knowledge ? model.knowledge.substring(0, 7) : "-"} - {model.release_date}{model.last_updated}
+
+ + + +
+
Loading models…
+

How to use

@@ -533,7 +301,8 @@ export const Rendered = renderToString(

- If we don't have a provider's logo, a default logo is served instead. + If we don't have a provider's logo, a default logo is served + instead.

Contribute

From 21a7b50846c82e5ef88330b25b6a4dd0f9fa7b04 Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:11:16 +0100 Subject: [PATCH 5/9] feat(web): add Tanstack Table column definitions (safe DOM construction) --- packages/web/src/columns.ts | 496 ++++++++++++++++++++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 packages/web/src/columns.ts diff --git a/packages/web/src/columns.ts b/packages/web/src/columns.ts new file mode 100644 index 000000000..a0d41f950 --- /dev/null +++ b/packages/web/src/columns.ts @@ -0,0 +1,496 @@ +import type { ColumnDef } from "@tanstack/table-core"; +import type { Row } from "./data"; + +export type ColumnMeta = { + dataType: "text" | "number" | "boolean" | "modalities" | "cost"; + headerLabel: string; + headerSubLabel?: string; +}; + +// ── Cell helpers (safe DOM construction, no innerHTML) ──────────────────────── + +function makeBooleanCell(value: boolean | undefined): HTMLElement { + const span = document.createElement("span"); + span.textContent = value === undefined ? "-" : value ? "Yes" : "No"; + return span; +} + +function makeCostCell(value: number | undefined): HTMLElement { + const span = document.createElement("span"); + span.textContent = value === undefined ? "-" : `$${value.toFixed(2)}`; + return span; +} + +// SVG paths for each modality — built via createElementNS so no innerHTML needed +type SvgPathSpec = { + tag: "polyline" | "line" | "rect" | "circle" | "path" | "polygon"; + attrs: Record; +}; + +const MODALITY_PATHS: Record = { + text: [ + { tag: "polyline", attrs: { points: "4,7 4,4 20,4 20,7" } }, + { tag: "line", attrs: { x1: "9", y1: "20", x2: "15", y2: "20" } }, + { tag: "line", attrs: { x1: "12", y1: "4", x2: "12", y2: "20" } }, + ], + image: [ + { + tag: "rect", + attrs: { width: "18", height: "18", x: "3", y: "3", rx: "2", ry: "2" }, + }, + { tag: "circle", attrs: { cx: "9", cy: "9", r: "2" } }, + { + tag: "path", + attrs: { d: "m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" }, + }, + ], + audio: [ + { + tag: "polygon", + attrs: { points: "11 5 6 9 2 9 2 15 6 15 11 19 11 5" }, + }, + { + tag: "path", + attrs: { + d: "m19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07", + }, + }, + ], + video: [ + { tag: "path", attrs: { d: "m22 8-6 4 6 4V8Z" } }, + { + tag: "rect", + attrs: { width: "14", height: "12", x: "2", y: "6", rx: "2", ry: "2" }, + }, + ], + pdf: [ + { + tag: "path", + attrs: { + d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z", + }, + }, + { tag: "polyline", attrs: { points: "14,2 14,8 20,8" } }, + { tag: "line", attrs: { x1: "16", y1: "13", x2: "8", y2: "13" } }, + { tag: "line", attrs: { x1: "16", y1: "17", x2: "8", y2: "17" } }, + ], +}; + +function buildModalityIcon(modality: string): SVGSVGElement | null { + const paths = MODALITY_PATHS[modality]; + if (!paths) return null; + + const NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(NS, "svg"); + svg.setAttribute("width", "16"); + svg.setAttribute("height", "16"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + + for (const { tag, attrs } of paths) { + const el = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + svg.append(el); + } + return svg; +} + +function makeModalitiesCell(modalities: string[]): HTMLElement { + const div = document.createElement("div"); + div.className = "modalities"; + for (const m of modalities) { + const svg = buildModalityIcon(m); + if (!svg) continue; + const span = document.createElement("span"); + span.className = "modality-icon"; + span.setAttribute("data-tooltip", m.charAt(0).toUpperCase() + m.slice(1)); + span.append(svg); + div.append(span); + } + return div; +} + +function makeCopyIcon(): SVGSVGElement { + const NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(NS, "svg"); + svg.classList.add("copy-icon"); + svg.setAttribute("width", "14"); + svg.setAttribute("height", "14"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + const rect = document.createElementNS(NS, "rect"); + rect.setAttribute("width", "14"); + rect.setAttribute("height", "14"); + rect.setAttribute("x", "8"); + rect.setAttribute("y", "8"); + rect.setAttribute("rx", "2"); + rect.setAttribute("ry", "2"); + const path = document.createElementNS(NS, "path"); + path.setAttribute( + "d", + "m4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" + ); + svg.append(rect, path); + return svg; +} + +function makeCheckIcon(): SVGSVGElement { + const NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(NS, "svg"); + svg.classList.add("check-icon"); + svg.setAttribute("width", "14"); + svg.setAttribute("height", "14"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.style.display = "none"; + const polyline = document.createElementNS(NS, "polyline"); + polyline.setAttribute("points", "20,6 9,17 4,12"); + svg.append(polyline); + return svg; +} + +function makeModelIdCell(modelId: string): HTMLElement { + const div = document.createElement("div"); + div.className = "model-id-cell"; + + const span = document.createElement("span"); + span.className = "model-id-text"; + span.textContent = modelId; + + const button = document.createElement("button"); + button.className = "copy-button"; + const copyIcon = makeCopyIcon(); + const checkIcon = makeCheckIcon(); + button.append(copyIcon, checkIcon); + + button.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(modelId); + copyIcon.style.display = "none"; + checkIcon.style.display = "block"; + setTimeout(() => { + copyIcon.style.display = "block"; + checkIcon.style.display = "none"; + }, 1000); + } catch { + // clipboard unavailable + } + }); + + div.append(span, button); + return div; +} + +function makeProviderCell( + providerId: string, + providerName: string +): HTMLElement { + const div = document.createElement("div"); + div.className = "provider-cell"; + + const img = document.createElement("img"); + img.src = `/logos/${providerId}.svg`; + img.alt = providerName; + img.width = 16; + img.height = 16; + img.className = "provider-logo"; + + const span = document.createElement("span"); + span.textContent = providerName; + + div.append(img, span); + return div; +} + +function makeNumberCell(value: number | undefined): HTMLElement { + const span = document.createElement("span"); + span.textContent = value == null ? "-" : value.toLocaleString(); + return span; +} + +// ── Column definitions ──────────────────────────────────────────────────────── + +export const columnDefs: ColumnDef[] = [ + { + id: "provider", + header: "Provider", + accessorFn: (row) => row.providerName, + size: 150, + meta: { dataType: "text", headerLabel: "Provider" } satisfies ColumnMeta, + cell: (info) => + makeProviderCell(info.row.original.providerId, info.getValue()), + }, + { + id: "model", + header: "Model", + accessorFn: (row) => row.name, + size: 200, + meta: { dataType: "text", headerLabel: "Model" } satisfies ColumnMeta, + }, + { + id: "family", + header: "Family", + accessorFn: (row) => row.family ?? "-", + size: 120, + meta: { dataType: "text", headerLabel: "Family" } satisfies ColumnMeta, + }, + { + id: "provider-id", + header: "Provider ID", + accessorFn: (row) => row.providerId, + size: 120, + meta: { dataType: "text", headerLabel: "Provider ID" } satisfies ColumnMeta, + }, + { + id: "model-id", + header: "Model ID", + accessorFn: (row) => row.modelId, + size: 220, + meta: { dataType: "text", headerLabel: "Model ID" } satisfies ColumnMeta, + cell: (info) => makeModelIdCell(info.getValue()), + }, + { + id: "tool-call", + header: "Tool Call", + accessorFn: (row) => row.tool_call, + size: 90, + sortingFn: "basic", + meta: { + dataType: "boolean", + headerLabel: "Tool Call", + } satisfies ColumnMeta, + cell: (info) => makeBooleanCell(info.getValue()), + }, + { + id: "reasoning", + header: "Reasoning", + accessorFn: (row) => row.reasoning, + size: 90, + sortingFn: "basic", + meta: { + dataType: "boolean", + headerLabel: "Reasoning", + } satisfies ColumnMeta, + cell: (info) => makeBooleanCell(info.getValue()), + }, + { + id: "input-modalities", + header: "Input", + accessorFn: (row) => row.modalities.input.length, + size: 100, + meta: { + dataType: "modalities", + headerLabel: "Input", + } satisfies ColumnMeta, + cell: (info) => makeModalitiesCell(info.row.original.modalities.input), + }, + { + id: "output-modalities", + header: "Output", + accessorFn: (row) => row.modalities.output.length, + size: 100, + meta: { + dataType: "modalities", + headerLabel: "Output", + } satisfies ColumnMeta, + cell: (info) => makeModalitiesCell(info.row.original.modalities.output), + }, + { + id: "input-cost", + header: "Input Cost", + accessorFn: (row) => row.cost?.input, + size: 110, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Input Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "output-cost", + header: "Output Cost", + accessorFn: (row) => row.cost?.output, + size: 115, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Output Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "reasoning-cost", + header: "Reasoning Cost", + accessorFn: (row) => row.cost?.reasoning, + size: 130, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Reasoning Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "cache-read-cost", + header: "Cache Read Cost", + accessorFn: (row) => row.cost?.cache_read, + size: 130, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Cache Read Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "cache-write-cost", + header: "Cache Write Cost", + accessorFn: (row) => row.cost?.cache_write, + size: 135, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Cache Write Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "audio-input-cost", + header: "Audio Input Cost", + accessorFn: (row) => row.cost?.input_audio, + size: 135, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Audio Input Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "audio-output-cost", + header: "Audio Output Cost", + accessorFn: (row) => row.cost?.output_audio, + size: 140, + sortUndefined: "last", + meta: { + dataType: "cost", + headerLabel: "Audio Output Cost", + headerSubLabel: "per 1M tokens", + } satisfies ColumnMeta, + cell: (info) => makeCostCell(info.getValue()), + }, + { + id: "context-limit", + header: "Context Limit", + accessorFn: (row) => row.limit.context, + size: 115, + meta: { + dataType: "number", + headerLabel: "Context Limit", + } satisfies ColumnMeta, + cell: (info) => makeNumberCell(info.getValue()), + }, + { + id: "input-limit", + header: "Input Limit", + accessorFn: (row) => row.limit.input, + size: 100, + sortUndefined: "last", + meta: { + dataType: "number", + headerLabel: "Input Limit", + } satisfies ColumnMeta, + cell: (info) => makeNumberCell(info.getValue()), + }, + { + id: "output-limit", + header: "Output Limit", + accessorFn: (row) => row.limit.output, + size: 110, + meta: { + dataType: "number", + headerLabel: "Output Limit", + } satisfies ColumnMeta, + cell: (info) => makeNumberCell(info.getValue()), + }, + { + id: "structured-output", + header: "Structured Output", + accessorFn: (row) => row.structured_output, + size: 145, + meta: { + dataType: "boolean", + headerLabel: "Structured Output", + } satisfies ColumnMeta, + cell: (info) => { + const v = info.getValue(); + const span = document.createElement("span"); + span.textContent = v === undefined ? "-" : v ? "Yes" : "No"; + return span; + }, + }, + { + id: "temperature", + header: "Temperature", + accessorFn: (row) => row.temperature, + size: 110, + meta: { + dataType: "boolean", + headerLabel: "Temperature", + } satisfies ColumnMeta, + cell: (info) => makeBooleanCell(info.getValue()), + }, + { + id: "weights", + header: "Weights", + accessorFn: (row) => (row.open_weights ? "Open" : "Closed"), + size: 90, + meta: { dataType: "text", headerLabel: "Weights" } satisfies ColumnMeta, + }, + { + id: "knowledge", + header: "Knowledge", + accessorFn: (row) => (row.knowledge ? row.knowledge.substring(0, 7) : "-"), + size: 105, + meta: { dataType: "text", headerLabel: "Knowledge" } satisfies ColumnMeta, + }, + { + id: "release-date", + header: "Release Date", + accessorFn: (row) => row.release_date ?? "-", + size: 115, + meta: { + dataType: "text", + headerLabel: "Release Date", + } satisfies ColumnMeta, + }, + { + id: "last-updated", + header: "Last Updated", + accessorFn: (row) => row.last_updated ?? "-", + size: 115, + meta: { + dataType: "text", + headerLabel: "Last Updated", + } satisfies ColumnMeta, + }, +]; From fdfe83c06e4a8781ade4c55081804e51d31fa58d Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:14:31 +0100 Subject: [PATCH 6/9] feat: rewrite index.ts with Tanstack Table + Virtual Replaces imperative sort/filter/render with declarative Tanstack Table (createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel) and virtual row rendering via Virtualizer class. Adds column picker dropdown, URL state sync (search/sort/order/cols), and keyboard shortcut Cmd+K to focus search. Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/index.ts | 612 +++++++++++++++++++++++++------------- 1 file changed, 411 insertions(+), 201 deletions(-) diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 81afb4342..caf818010 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -1,240 +1,450 @@ +import { + createTable, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + type SortingState, + type VisibilityState, +} from "@tanstack/table-core"; +import { + Virtualizer, + observeElementRect, + observeElementOffset, + elementScroll, + measureElement, +} from "@tanstack/virtual-core"; +import { flattenProviders, type Row } from "./data"; +import { columnDefs, type ColumnMeta } from "./columns"; +import { parseUrlState, serializeUrlState, ALL_COLUMN_IDS } from "./url-state"; + +// ─── DOM refs ────────────────────────────────────────────────────────────────── +const scrollContainer = document.getElementById( + "table-scroll-container" +) as HTMLDivElement; +const tableHead = document.getElementById( + "table-head" +) as HTMLTableSectionElement; +const tableBody = document.getElementById( + "table-body" +) as HTMLTableSectionElement; +const tableLoading = document.getElementById("table-loading") as HTMLDivElement; +const searchInput = document.getElementById("search") as HTMLInputElement; +const columnsToggle = document.getElementById( + "columns-toggle" +) as HTMLButtonElement; +const columnsPicker = document.getElementById( + "columns-picker" +) as HTMLDivElement; const modal = document.getElementById("modal") as HTMLDialogElement; -const modalClose = document.getElementById("close")!; -const help = document.getElementById("help")!; -const search = document.getElementById("search")! as HTMLInputElement; - -///////////////////////// -// URL State Management -///////////////////////// -function getQueryParams() { - return new URLSearchParams(window.location.search); +const modalClose = document.getElementById("close") as HTMLButtonElement; +const helpButton = document.getElementById("help") as HTMLButtonElement; + +// ─── State ──────────────────────────────────────────────────────────────────── +let rows: Row[] = []; +let sorting: SortingState = []; +let globalFilter = ""; +let columnVisibility: VisibilityState = {}; + +// ─── Tanstack Table ─────────────────────────────────────────────────────────── +const table = createTable({ + data: rows, + columns: columnDefs, + state: { sorting, globalFilter, columnVisibility }, + onStateChange: () => {}, + renderFallbackValue: null, + onSortingChange: (updater) => { + sorting = typeof updater === "function" ? updater(sorting) : updater; + afterStateChange(); + }, + onGlobalFilterChange: (updater) => { + globalFilter = + typeof updater === "function" ? updater(globalFilter) : updater; + afterStateChange(); + }, + onColumnVisibilityChange: (updater) => { + columnVisibility = + typeof updater === "function" ? updater(columnVisibility) : updater; + afterStateChange(); + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + globalFilterFn: (row, _columnId, filterValue: string) => { + const terms = filterValue + .toLowerCase() + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + if (terms.length === 0) return true; + const orig = row.original as Record; + const text = Object.values(orig) + .map((v) => + typeof v === "string" || typeof v === "number" ? String(v) : "" + ) + .join(" ") + .toLowerCase(); + return terms.some((term) => text.includes(term)); + }, +}); + +function afterStateChange() { + table.setOptions((prev) => ({ + ...prev, + data: rows, + state: { sorting, globalFilter, columnVisibility }, + })); + const rowCount = table.getRowModel().rows.length; + virtualizer.setOptions({ + ...virtualizer.options, + count: rowCount, + }); + virtualizer.scrollToOffset(0); + renderHead(); + renderRows(); + updateUrl(); + updateColumnPickerCheckboxes(); } -function updateQueryParams(updates: Record) { - const params = getQueryParams(); - for (const [key, value] of Object.entries(updates)) { - if (value) { - params.set(key, value); - } else { - params.delete(key); +// ─── Tanstack Virtual ───────────────────────────────────────────────────────── +const virtualizer = new Virtualizer({ + count: 0, + getScrollElement: () => scrollContainer, + estimateSize: () => 45, + overscan: 5, + scrollToFn: elementScroll, + observeElementRect, + observeElementOffset, + measureElement, + onChange: () => renderRows(), +}); + +// ─── Render: thead ──────────────────────────────────────────────────────────── +function renderHead() { + tableHead.textContent = ""; + const tr = document.createElement("tr"); + tr.style.cssText = "display: flex; width: 100%;"; + + for (const headerGroup of table.getHeaderGroups()) { + for (const header of headerGroup.headers) { + if (!header.column.getIsVisible()) continue; + + const meta = header.column.columnDef.meta as ColumnMeta | undefined; + const colSize = header.column.getSize(); + const isSorted = header.column.getIsSorted(); + + const th = document.createElement("th"); + th.className = "sortable"; + th.style.cssText = `width: ${colSize}px; flex: 0 0 ${colSize}px; overflow: hidden;`; + th.setAttribute("data-column-id", header.column.id); + + if (meta?.headerSubLabel) { + const container = document.createElement("div"); + container.className = "header-container"; + + const textSpan = document.createElement("span"); + textSpan.className = "header-text"; + textSpan.textContent = meta.headerLabel; + + const descSpan = document.createElement("span"); + descSpan.className = "desc"; + descSpan.textContent = meta.headerSubLabel; + textSpan.append(document.createElement("br"), descSpan); + + const sortSpan = document.createElement("span"); + sortSpan.className = "sort-indicator"; + sortSpan.textContent = + isSorted === "asc" ? "↑" : isSorted === "desc" ? "↓" : ""; + + container.append(textSpan, sortSpan); + th.append(container); + } else { + const label = meta?.headerLabel ?? header.column.id; + const sortIndicator = + isSorted === "asc" ? " ↑" : isSorted === "desc" ? " ↓" : ""; + th.textContent = label + sortIndicator; + } + + th.addEventListener("click", () => { + header.column.toggleSorting(header.column.getIsSorted() === "asc"); + }); + + tr.append(th); } } - const newPath = params.toString() - ? `${window.location.pathname}?${params.toString()}` - : window.location.pathname; - window.history.pushState({}, "", newPath); -} -function getColumnNameForURL(headerEl: Element): string { - const text = headerEl.textContent?.trim().toLowerCase() || ""; - return text.replace(/↑|↓/g, "").trim().split(/\s+/).slice(0, 2).join("-"); + tableHead.append(tr); } -function getColumnIndexByUrlName(name: string): number { - const headers = document.querySelectorAll("th.sortable"); - return Array.from(headers).findIndex( - (header) => getColumnNameForURL(header) === name - ); +// ─── Render: tbody (virtual) ────────────────────────────────────────────────── +function renderRows() { + const virtualItems = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + const tableRows = table.getRowModel().rows; + + tableBody.style.cssText = `height: ${totalSize}px; position: relative;`; + tableBody.textContent = ""; + + for (const virtualRow of virtualItems) { + const row = tableRows[virtualRow.index]; + if (!row) continue; + + const tr = document.createElement("tr"); + tr.style.cssText = [ + "position: absolute", + "top: 0", + `transform: translateY(${virtualRow.start}px)`, + "display: flex", + "width: 100%", + ].join("; "); + tr.dataset.index = String(virtualRow.index); + + for (const cell of row.getVisibleCells()) { + const td = document.createElement("td"); + const colSize = cell.column.getSize(); + td.style.cssText = `width: ${colSize}px; flex: 0 0 ${colSize}px; overflow: hidden;`; + + const colDef = cell.column.columnDef; + if (typeof colDef.cell === "function") { + const rendered = colDef.cell(cell.getContext()); + if (rendered instanceof HTMLElement) { + td.append(rendered); + } else if (rendered != null) { + td.textContent = String(rendered); + } + } else { + const v = cell.getValue(); + td.textContent = v != null ? String(v) : "-"; + } + + tr.append(td); + } + + tableBody.append(tr); + } } -///////////////////////// -// Handle "How to use" -///////////////////////// -let y = 0; +// ─── Column picker ──────────────────────────────────────────────────────────── +const COLUMN_GROUPS: { label: string; ids: string[] }[] = [ + { + label: "Identity", + ids: ["provider", "model", "family", "provider-id", "model-id"], + }, + { + label: "Capabilities", + ids: [ + "tool-call", + "reasoning", + "structured-output", + "temperature", + "weights", + ], + }, + { label: "Modalities", ids: ["input-modalities", "output-modalities"] }, + { + label: "Cost", + ids: [ + "input-cost", + "output-cost", + "reasoning-cost", + "cache-read-cost", + "cache-write-cost", + "audio-input-cost", + "audio-output-cost", + ], + }, + { label: "Limits", ids: ["context-limit", "input-limit", "output-limit"] }, + { label: "Metadata", ids: ["knowledge", "release-date", "last-updated"] }, +]; + +function buildColumnPicker() { + columnsPicker.textContent = ""; + + const actions = document.createElement("div"); + actions.className = "picker-actions"; + const showAll = document.createElement("button"); + showAll.textContent = "Show all"; + showAll.addEventListener("click", () => { + table.toggleAllColumnsVisible(true); + }); + actions.append(showAll); + columnsPicker.append(actions); + + for (const group of COLUMN_GROUPS) { + const groupEl = document.createElement("div"); + groupEl.className = "picker-group"; + + const groupLabel = document.createElement("div"); + groupLabel.className = "picker-group-label"; + groupLabel.textContent = group.label; + groupEl.append(groupLabel); + + for (const colId of group.ids) { + const col = table.getColumn(colId); + if (!col) continue; + const meta = col.columnDef.meta as ColumnMeta | undefined; + + const label = document.createElement("label"); + label.className = "picker-item"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = col.getIsVisible(); + checkbox.dataset.colId = colId; + checkbox.addEventListener("change", () => { + col.toggleVisibility(checkbox.checked); + }); + + const labelText = document.createTextNode(meta?.headerLabel ?? colId); + label.append(checkbox, labelText); + groupEl.append(label); + } -help.addEventListener("click", () => { - y = window.scrollY; - document.body.style.position = "fixed"; - document.body.style.top = `-${y}px`; - modal.showModal(); -}); + columnsPicker.append(groupEl); + } +} -function closeDialog() { - modal.close(); - document.body.style.position = ""; - document.body.style.top = ""; - window.scrollTo(0, y); +function updateColumnPickerCheckboxes() { + columnsPicker + .querySelectorAll("[data-col-id]") + .forEach((cb) => { + const col = table.getColumn(cb.dataset.colId ?? ""); + if (col) cb.checked = col.getIsVisible(); + }); } -modalClose.addEventListener("click", closeDialog); -modal.addEventListener("cancel", closeDialog); -modal.addEventListener("click", (e) => { - if (e.target === modal) closeDialog(); +columnsToggle.addEventListener("click", (e) => { + e.stopPropagation(); + const isOpen = !columnsPicker.hidden; + columnsPicker.hidden = isOpen; + if (!isOpen) buildColumnPicker(); }); -//////////////////// -// Handle Sorting -//////////////////// -let currentSort = { column: -1, direction: "asc" }; - -function sortTable(column: number, direction: "asc" | "desc") { - const header = document.querySelectorAll("th.sortable")[column]; - const columnType = header.getAttribute("data-type"); - if (!columnType) return; - - // update state - currentSort = { column, direction }; - updateQueryParams({ - sort: getColumnNameForURL(header), - order: direction, - }); +document.addEventListener("click", (e) => { + if ( + !columnsPicker.hidden && + !columnsPicker.contains(e.target as Node) && + e.target !== columnsToggle + ) { + columnsPicker.hidden = true; + } +}); - // sort rows - const tbody = document.querySelector("table tbody")!; - const rows = Array.from( - tbody.querySelectorAll("tr") - ) as HTMLTableRowElement[]; - rows.sort((a, b) => { - const aValue = getCellValue(a.cells[column], columnType); - const bValue = getCellValue(b.cells[column], columnType); - - // Handle undefined values - always sort to bottom - if (aValue === undefined && bValue === undefined) return 0; - if (aValue === undefined) return 1; - if (bValue === undefined) return -1; - - let comparison = 0; - if (columnType === "number" || columnType === "modalities") { - comparison = (aValue as number) - (bValue as number); - } else if (columnType === "boolean") { - comparison = (aValue as string).localeCompare(bValue as string); - } else { - comparison = (aValue as string).localeCompare(bValue as string); - } +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !columnsPicker.hidden) { + columnsPicker.hidden = true; + } +}); - return direction === "asc" ? comparison : -comparison; +// ─── URL state ──────────────────────────────────────────────────────────────── +function updateUrl() { + const visibleCols = table + .getAllColumns() + .filter((c) => c.getIsVisible()) + .map((c) => c.id); + + const params = serializeUrlState({ + search: globalFilter, + sort: sorting[0]?.id ?? null, + order: sorting[0]?.desc ? "desc" : "asc", + cols: visibleCols, }); - rows.forEach((row) => tbody.appendChild(row)); - - // update sort indicators - const headers = document.querySelectorAll("th.sortable"); - headers.forEach((header, i) => { - const indicator = header.querySelector(".sort-indicator")!; - if (i === column) { - indicator.textContent = direction === "asc" ? "↑" : "↓"; - } else { - indicator.textContent = ""; - } - }); + const newPath = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + window.history.pushState({}, "", newPath); } -function getCellValue( - cell: HTMLTableCellElement, - type: string -): string | number | undefined { - if (type === "modalities") - return cell.querySelectorAll(".modality-icon").length; - - const text = cell.textContent?.trim() || ""; - if (text === "-") return; - if (type === "number") return parseFloat(text.replace(/[$,]/g, "")) || 0; - return text; -} +function applyUrlState() { + const state = parseUrlState(new URLSearchParams(window.location.search)); -document.querySelectorAll("th.sortable").forEach((header) => { - header.addEventListener("click", () => { - const column = Array.from(header.parentElement!.children).indexOf(header); - const direction = - currentSort.column === column && currentSort.direction === "asc" - ? "desc" - : "asc"; - sortTable(column, direction); - }); -}); + globalFilter = state.search; + searchInput.value = state.search; + sorting = state.sort + ? [{ id: state.sort, desc: state.order === "desc" }] + : []; -/////////////////// -// Handle Search -/////////////////// -function filterTable(value: string) { - const lowerCaseValues = value.toLowerCase().split(",").filter(str => str.trim() !== ""); - const rows = document.querySelectorAll( - "table tbody tr" - ) as NodeListOf; - - rows.forEach((row) => { - const cellTexts = Array.from(row.cells).map((cell) => - cell.textContent!.toLowerCase() - ); - const isVisible = lowerCaseValues.length === 0 || - lowerCaseValues.some((lowerCaseValue) => cellTexts.some((text) => text.includes(lowerCaseValue))); - row.style.display = isVisible ? "" : "none"; - }); + const newVisibility: VisibilityState = {}; + for (const id of ALL_COLUMN_IDS) { + newVisibility[id] = state.cols.includes(id); + } + columnVisibility = newVisibility; + + table.setOptions((prev) => ({ + ...prev, + data: rows, + state: { sorting, globalFilter, columnVisibility }, + })); - updateQueryParams({ search: value || null }); + const rowCount = table.getRowModel().rows.length; + virtualizer.setOptions({ ...virtualizer.options, count: rowCount }); + virtualizer.scrollToOffset(0); + + renderHead(); + renderRows(); + buildColumnPicker(); } -search.addEventListener("input", () => { - filterTable(search.value); +// ─── Search ─────────────────────────────────────────────────────────────────── +searchInput.addEventListener("input", () => { + table.setGlobalFilter(searchInput.value); +}); + +searchInput.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + searchInput.value = ""; + table.setGlobalFilter(""); + } }); document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); - search.focus(); + searchInput.focus(); } }); -search.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - search.value = ""; - search.dispatchEvent(new Event("input")); - } +// ─── Help modal ─────────────────────────────────────────────────────────────── +let savedScrollY = 0; + +helpButton.addEventListener("click", () => { + savedScrollY = window.scrollY; + document.body.style.position = "fixed"; + document.body.style.top = `-${savedScrollY}px`; + modal.showModal(); }); -/////////////////////////////////// -// Handle Copy model ID function -/////////////////////////////////// -(window as any).copyModelId = async ( - button: HTMLButtonElement, - modelId: string -) => { - try { - if (navigator.clipboard) { - await navigator.clipboard.writeText(modelId); - - // Switch to check icon - const copyIcon = button.querySelector(".copy-icon") as HTMLElement; - const checkIcon = button.querySelector(".check-icon") as HTMLElement; - - copyIcon.style.display = "none"; - checkIcon.style.display = "block"; - - // Switch back after 1 second - setTimeout(() => { - copyIcon.style.display = "block"; - checkIcon.style.display = "none"; - }, 1000); - } - } catch (err) { - console.error("Failed to copy text: ", err); - } -}; - -/////////////////////////////////// -// Initialize State from URL -/////////////////////////////////// -function initializeFromURL() { - const params = getQueryParams(); - - (() => { - const searchQuery = params.get("search"); - if (!searchQuery) return; - search.value = searchQuery; - filterTable(searchQuery); - })(); - - (() => { - const columnName = params.get("sort"); - if (!columnName) return; - - const columnIndex = getColumnIndexByUrlName(columnName); - if (columnIndex === -1) return; - - const direction = (params.get("order") as "asc" | "desc") || "asc"; - sortTable(columnIndex, direction); - })(); +function closeDialog() { + modal.close(); + document.body.style.position = ""; + document.body.style.top = ""; + window.scrollTo(0, savedScrollY); } -document.addEventListener("DOMContentLoaded", initializeFromURL); -window.addEventListener("popstate", initializeFromURL); +modalClose.addEventListener("click", closeDialog); +modal.addEventListener("cancel", closeDialog); +modal.addEventListener("click", (e) => { + if (e.target === modal) closeDialog(); +}); + +// ─── Init ───────────────────────────────────────────────────────────────────── +async function init() { + const res = await fetch("/api.json"); + const api: Record = await res.json(); + rows = flattenProviders(api as any); + + // Default sort: provider name, then model name + rows.sort((a, b) => { + const p = a.providerName.localeCompare(b.providerName); + return p !== 0 ? p : a.name.localeCompare(b.name); + }); + + table.setOptions((prev) => ({ ...prev, data: rows })); + tableLoading.remove(); + applyUrlState(); +} + +window.addEventListener("popstate", applyUrlState); +document.addEventListener("DOMContentLoaded", () => { + init().catch(console.error); +}); From 51b481d42187a93d41cab883949ed181cdc7bdd3 Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 07:17:05 +0100 Subject: [PATCH 7/9] feat: update CSS for virtual scroll and column picker; add data-column-id to tds - Add #table-scroll-container with full-viewport scroll height - Make thead th sticky at top:0 within scroll container - Replace nth-child column selectors with data-column-id attribute selectors - Add column picker dropdown styles (.picker-group, .picker-item, etc.) - Add .provider-logo img style; remove stale .provider-cell svg rule - Add #table-loading style - Add data-column-id to each td in renderRows() Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/index.css | 172 +++++++++++++++++++++++++++++-------- packages/web/src/index.ts | 1 + 2 files changed, 137 insertions(+), 36 deletions(-) diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 58a4a5c4e..58ec17a93 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -143,6 +143,11 @@ header { min-width: 12.5rem; } + .columns-container { + position: relative; + flex: 0 0 auto; + } + input { width: 100%; font-size: 0.8125rem; @@ -205,12 +210,101 @@ header { } } +/* ─── Column picker dropdown ────────────────────────────────────────────────── */ +#columns-picker { + position: absolute; + top: calc(100% + 0.25rem); + right: 0; + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 0.375rem; + box-shadow: + 0 4px 8px rgba(0, 0, 0, 0.08), + 0 8px 24px rgba(0, 0, 0, 0.1); + padding: 0.375rem; + z-index: 100; + min-width: 11rem; + max-height: 32rem; + overflow-y: auto; +} + +.picker-actions { + padding: 0.25rem 0.5rem 0.5rem; + border-bottom: 1px solid var(--color-border); + margin-bottom: 0.375rem; + + button { + background: none; + color: var(--color-brand); + border: none; + font-size: 0.75rem; + cursor: pointer; + padding: 0; + height: auto; + line-height: 1; + + &:hover { + text-decoration: underline; + } + } +} + +.picker-group { + margin-bottom: 0.375rem; + + &:last-child { + margin-bottom: 0; + } +} + +.picker-group-label { + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-tertiary); + padding: 0.25rem 0.5rem; + font-weight: 500; +} + +.picker-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.8125rem; + + &:hover { + background-color: var(--color-surface); + } + + input[type="checkbox"] { + cursor: pointer; + accent-color: var(--color-brand); + } +} + +/* ─── Virtual scroll container ──────────────────────────────────────────────── */ +#table-scroll-container { + height: calc(100svh - var(--header-height)); + overflow-y: auto; + overflow-x: auto; + margin-top: var(--header-height); +} + +#table-loading { + padding: 2rem; + text-align: center; + color: var(--color-text-tertiary); + font-size: 0.875rem; +} + table { border-collapse: separate; border-spacing: 0; font-size: 0.875rem; width: 100%; - margin-top: var(--header-height); } thead, @@ -218,7 +312,7 @@ tbody {} table thead th { position: sticky; - top: var(--header-height); + top: 0; border-top: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border); font-size: 0.75rem; @@ -271,44 +365,53 @@ tbody { color: var(--color-text-tertiary); } - td:nth-child(1) { + /* Provider column — bold name */ + td[data-column-id="provider"] { font-weight: 500; + color: var(--color-text); } - td:nth-child(1), - td:nth-child(2), - td:nth-child(5), - td:nth-child(6), - td:nth-child(9), - td:nth-child(10), - td:nth-child(11), - td:nth-child(12), - td:nth-child(13), - td:nth-child(14), - td:nth-child(15), - td:nth-child(16) { + /* Main identity columns — full text color */ + td[data-column-id="model"], + td[data-column-id="family"], + td[data-column-id="weights"] { color: var(--color-text); } - td:nth-child(5), - td:nth-child(6), - td:nth-child(18) { + /* ID columns — mono, uppercase */ + td[data-column-id="provider-id"], + td[data-column-id="model-id"] { font-size: 0.8125rem; font-family: var(--font-mono); - text-transform: uppercase; + color: var(--color-text); + } + + /* Cost columns — mono */ + td[data-column-id="input-cost"], + td[data-column-id="output-cost"], + td[data-column-id="reasoning-cost"], + td[data-column-id="cache-read-cost"], + td[data-column-id="cache-write-cost"], + td[data-column-id="audio-input-cost"], + td[data-column-id="audio-output-cost"] { + font-size: 0.8125rem; + font-family: var(--font-mono); + color: var(--color-text); + } + + /* Limit columns — mono */ + td[data-column-id="context-limit"], + td[data-column-id="input-limit"], + td[data-column-id="output-limit"] { + font-size: 0.8125rem; + font-family: var(--font-mono); + color: var(--color-text); } - td:nth-child(3), - td:nth-child(4), - td:nth-child(9), - td:nth-child(10), - td:nth-child(11), - td:nth-child(12), - td:nth-child(13), - td:nth-child(14), - td:nth-child(15), - td:nth-child(16), - td:nth-child(17) { + /* Date/metadata columns — mono */ + td[data-column-id="knowledge"], + td[data-column-id="release-date"], + td[data-column-id="last-updated"] { font-size: 0.8125rem; font-family: var(--font-mono); } @@ -319,15 +422,12 @@ tbody { gap: 0.375rem; } - .provider-cell span:first-child { - flex: 0 0 auto; - } - - .provider-cell svg { + .provider-logo { display: block; + flex: 0 0 auto; width: 1rem; height: 1rem; - color: var(--color-text-secondary); + object-fit: contain; } .model-id-cell { diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index caf818010..134e4faed 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -202,6 +202,7 @@ function renderRows() { const td = document.createElement("td"); const colSize = cell.column.getSize(); td.style.cssText = `width: ${colSize}px; flex: 0 0 ${colSize}px; overflow: hidden;`; + td.dataset.columnId = cell.column.id; const colDef = cell.column.columnDef; if (typeof colDef.cell === "function") { From 8dc67e40f2592bebf045a06ecb50185d010555c6 Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 08:04:42 +0100 Subject: [PATCH 8/9] fix: call virtualizer._willUpdate() and add /api.json dev route In vanilla JS, Virtualizer._willUpdate() must be called manually to initialize scroll element observation (frameworks call it automatically via lifecycle hooks like useLayoutEffect). Without it, scrollElement stays null, observers are never set up, and getVirtualItems() always returns an empty array. Also added /api.json route to the dev server so the client-side fetch succeeds in development mode. Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/index.ts | 16 ++++++++++++++-- packages/web/src/server.ts | 6 +++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 134e4faed..2c825e8e8 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -87,17 +87,25 @@ const table = createTable({ }, }); +// Prime state with all feature-provided defaults (e.g. columnPinning: {left:[],right:[]}) +// so that getHeaderGroups() never reads undefined.left +table.setOptions((prev) => ({ + ...prev, + state: { ...table.initialState, ...prev.state }, +})); + function afterStateChange() { table.setOptions((prev) => ({ ...prev, data: rows, - state: { sorting, globalFilter, columnVisibility }, + state: { ...prev.state, sorting, globalFilter, columnVisibility }, })); const rowCount = table.getRowModel().rows.length; virtualizer.setOptions({ ...virtualizer.options, count: rowCount, }); + virtualizer._willUpdate(); virtualizer.scrollToOffset(0); renderHead(); renderRows(); @@ -117,6 +125,9 @@ const virtualizer = new Virtualizer({ measureElement, onChange: () => renderRows(), }); +// In vanilla JS, _willUpdate() must be called manually to start observing +// the scroll element (frameworks call it automatically via lifecycle hooks). +virtualizer._willUpdate(); // ─── Render: thead ──────────────────────────────────────────────────────────── function renderHead() { @@ -374,11 +385,12 @@ function applyUrlState() { table.setOptions((prev) => ({ ...prev, data: rows, - state: { sorting, globalFilter, columnVisibility }, + state: { ...prev.state, sorting, globalFilter, columnVisibility }, })); const rowCount = table.getRowModel().rows.length; virtualizer.setOptions({ ...virtualizer.options, count: rowCount }); + virtualizer._willUpdate(); virtualizer.scrollToOffset(0); renderHead(); diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 5302f92d5..c460353ee 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -1,11 +1,15 @@ import Index from "../index.html"; -import { Rendered } from "./render"; +import { Rendered, Providers } from "./render"; import path from "path"; Bun.serve({ port: 16_000, routes: { "/": Index, + "/api.json": () => + new Response(JSON.stringify(Providers), { + headers: { "Content-Type": "application/json" }, + }), "/assets/*": (req) => { const file = Bun.file( path.join(import.meta.dir, new URL(req.url).pathname) From c2c05609ae5fe080db17ae7c3de96d2b1d9af8d4 Mon Sep 17 00:00:00 2001 From: Gianfranco Palumbo Date: Tue, 24 Feb 2026 09:03:46 +0100 Subject: [PATCH 9/9] fix column container and auto formatting with biome --- packages/web/src/columns.ts | 4 +- packages/web/src/data.test.ts | 2 +- packages/web/src/index.css | 63 +++++++++++++++++------------- packages/web/src/index.ts | 24 ++++++------ packages/web/src/render.tsx | 22 ++++++----- packages/web/src/server.ts | 10 ++--- packages/web/src/url-state.test.ts | 12 ++++-- packages/web/src/url-state.ts | 32 +++++++++++---- 8 files changed, 102 insertions(+), 67 deletions(-) diff --git a/packages/web/src/columns.ts b/packages/web/src/columns.ts index a0d41f950..9b56a214b 100644 --- a/packages/web/src/columns.ts +++ b/packages/web/src/columns.ts @@ -136,7 +136,7 @@ function makeCopyIcon(): SVGSVGElement { const path = document.createElementNS(NS, "path"); path.setAttribute( "d", - "m4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" + "m4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2", ); svg.append(rect, path); return svg; @@ -195,7 +195,7 @@ function makeModelIdCell(modelId: string): HTMLElement { function makeProviderCell( providerId: string, - providerName: string + providerName: string, ): HTMLElement { const div = document.createElement("div"); div.className = "provider-cell"; diff --git a/packages/web/src/data.test.ts b/packages/web/src/data.test.ts index 730a52ebd..dec870a75 100644 --- a/packages/web/src/data.test.ts +++ b/packages/web/src/data.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "bun:test"; +import { describe, expect, it } from "bun:test"; import { flattenProviders } from "./data"; const mockApi = { diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 58ec17a93..e87f6afaa 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -8,40 +8,40 @@ :root { --icon-opacity: 0.85; --header-height: 56px; - --font-mono: 'IBM Plex Mono', monospace; + --font-mono: "IBM Plex Mono", monospace; } :root { - --color-brand: #FD9527; - --color-background: #FFF; - --color-border: #DDD; - --color-surface: #EEE; + --color-brand: #fd9527; + --color-background: #fff; + --color-border: #ddd; + --color-surface: #eee; --color-alpha-background: rgba(255, 255, 255, 0.75); --color-text: #333; - --color-text-invert: #FFF; + --color-text-invert: #fff; --color-text-secondary: #666; --color-text-tertiary: #999; } @media (prefers-color-scheme: dark) { :root { - --color-brand: #FD9527; - --color-background: #1E1E1E; + --color-brand: #fd9527; + --color-background: #1e1e1e; --color-border: #333; --color-surface: #111; --color-alpha-background: rgba(30, 30, 30, 0.75); - --color-text: #FFF; + --color-text: #fff; --color-text-invert: #333; - --color-text-secondary: #AAA; + --color-text-secondary: #aaa; --color-text-tertiary: #666; } } html, body { - font-family: 'Rubik', sans-serif; + font-family: "Rubik", sans-serif; line-height: 1.6; color: var(--color-text); background-color: var(--color-background); @@ -81,7 +81,7 @@ header { width: 100%; z-index: 10; - &>div { + & > div { display: flex; align-items: center; @@ -173,7 +173,8 @@ header { font-size: 0.75rem; color: var(--color-text-tertiary); pointer-events: none; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; } button { @@ -191,7 +192,6 @@ header { @media (max-width: 32rem) { div.left { - p, span.slash { display: none; @@ -201,7 +201,6 @@ header { @media (max-width: 45rem) { div.right { - .github, .search-container { display: none; @@ -222,12 +221,17 @@ header { 0 4px 8px rgba(0, 0, 0, 0.08), 0 8px 24px rgba(0, 0, 0, 0.1); padding: 0.375rem; - z-index: 100; + z-index: 10; min-width: 11rem; max-height: 32rem; overflow-y: auto; } +#table-head { + z-index: 1; + position: relative; +} + .picker-actions { padding: 0.25rem 0.5rem 0.5rem; border-bottom: 1px solid var(--color-border); @@ -282,6 +286,8 @@ header { input[type="checkbox"] { cursor: pointer; accent-color: var(--color-brand); + width: 1rem; + height: 1rem; } } @@ -308,7 +314,8 @@ table { } thead, -tbody {} +tbody { +} table thead th { position: sticky; @@ -437,7 +444,8 @@ tbody { gap: 0.375rem; } - .model-id-text {} + .model-id-text { + } .copy-button { flex: 0 0 auto; @@ -448,7 +456,9 @@ tbody { border-radius: 0.25rem; color: var(--color-text-tertiary); opacity: 0; - transition: opacity 0.2s ease, color 0.2s ease; + transition: + opacity 0.2s ease, + color 0.2s ease; } .model-id-cell:hover .copy-button { @@ -533,12 +543,12 @@ dialog { max-width: 40rem; max-height: calc(100svh - 2rem); box-shadow: - 0 2px 4px rgba(0, 0, 0, .05), - 0 4px 8px rgba(0, 0, 0, .05), - 0 8px 16px rgba(0, 0, 0, .07), - 0 16px 32px rgba(0, 0, 0, .07), - 0 32px 64px rgba(0, 0, 0, .07), - 0 48px 96px rgba(0, 0, 0, .07); + 0 2px 4px rgba(0, 0, 0, 0.05), + 0 4px 8px rgba(0, 0, 0, 0.05), + 0 8px 16px rgba(0, 0, 0, 0.07), + 0 16px 32px rgba(0, 0, 0, 0.07), + 0 32px 64px rgba(0, 0, 0, 0.07), + 0 48px 96px rgba(0, 0, 0, 0.07); flex-direction: column; overflow: hidden; @@ -645,5 +655,4 @@ dialog { } } } - -} \ No newline at end of file +} diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 2c825e8e8..9b2161dfb 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -1,39 +1,39 @@ import { createTable, getCoreRowModel, - getSortedRowModel, getFilteredRowModel, + getSortedRowModel, type SortingState, type VisibilityState, } from "@tanstack/table-core"; import { - Virtualizer, - observeElementRect, - observeElementOffset, elementScroll, measureElement, + observeElementOffset, + observeElementRect, + Virtualizer, } from "@tanstack/virtual-core"; +import { type ColumnMeta, columnDefs } from "./columns"; import { flattenProviders, type Row } from "./data"; -import { columnDefs, type ColumnMeta } from "./columns"; -import { parseUrlState, serializeUrlState, ALL_COLUMN_IDS } from "./url-state"; +import { ALL_COLUMN_IDS, parseUrlState, serializeUrlState } from "./url-state"; // ─── DOM refs ────────────────────────────────────────────────────────────────── const scrollContainer = document.getElementById( - "table-scroll-container" + "table-scroll-container", ) as HTMLDivElement; const tableHead = document.getElementById( - "table-head" + "table-head", ) as HTMLTableSectionElement; const tableBody = document.getElementById( - "table-body" + "table-body", ) as HTMLTableSectionElement; const tableLoading = document.getElementById("table-loading") as HTMLDivElement; const searchInput = document.getElementById("search") as HTMLInputElement; const columnsToggle = document.getElementById( - "columns-toggle" + "columns-toggle", ) as HTMLButtonElement; const columnsPicker = document.getElementById( - "columns-picker" + "columns-picker", ) as HTMLDivElement; const modal = document.getElementById("modal") as HTMLDialogElement; const modalClose = document.getElementById("close") as HTMLButtonElement; @@ -79,7 +79,7 @@ const table = createTable({ const orig = row.original as Record; const text = Object.values(orig) .map((v) => - typeof v === "string" || typeof v === "number" ? String(v) : "" + typeof v === "string" || typeof v === "number" ? String(v) : "", ) .join(" ") .toLowerCase(); diff --git a/packages/web/src/render.tsx b/packages/web/src/render.tsx index a5b60b064..3650f3307 100644 --- a/packages/web/src/render.tsx +++ b/packages/web/src/render.tsx @@ -1,14 +1,14 @@ /** @jsx jsx */ /** @jsxImportSource hono/jsx */ -import { generate } from "models.dev"; +import { existsSync } from "fs"; import { Fragment } from "hono/jsx"; import { renderToString } from "hono/jsx/dom/server"; -import { existsSync } from "fs"; +import { generate } from "models.dev"; import path from "path"; export const Providers = await generate( - path.join(import.meta.dir, "..", "..", "..", "providers") + path.join(import.meta.dir, "..", "..", "..", "providers"), ); // Function to load SVG content @@ -20,7 +20,7 @@ const loadProviderSvg = async (providerId: string): Promise => { "..", "providers", providerId, - "logo.svg" + "logo.svg", ); const defaultLogoPath = path.join( @@ -29,7 +29,7 @@ const loadProviderSvg = async (providerId: string): Promise => { "..", "..", "providers", - "logo.svg" + "logo.svg", ); try { @@ -211,10 +211,14 @@ export const Rendered = renderToString( ⌘K

- +
- +
@@ -227,7 +231,7 @@ export const Rendered = renderToString(

How to use

-
- + , ); diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index c460353ee..5bb81787b 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -1,6 +1,6 @@ -import Index from "../index.html"; -import { Rendered, Providers } from "./render"; import path from "path"; +import Index from "../index.html"; +import { Providers, Rendered } from "./render"; Bun.serve({ port: 16_000, @@ -12,7 +12,7 @@ Bun.serve({ }), "/assets/*": (req) => { const file = Bun.file( - path.join(import.meta.dir, new URL(req.url).pathname) + path.join(import.meta.dir, new URL(req.url).pathname), ); return new Response(file); }, @@ -26,7 +26,7 @@ Bun.serve({ "..", "providers", provider, - "logo.svg" + "logo.svg", ); const defaultLogoPath = path.join( import.meta.dir, @@ -34,7 +34,7 @@ Bun.serve({ "..", "..", "providers", - "logo.svg" + "logo.svg", ); let file = Bun.file(logoPath); diff --git a/packages/web/src/url-state.test.ts b/packages/web/src/url-state.test.ts index a6ac2e3e8..40d373038 100644 --- a/packages/web/src/url-state.test.ts +++ b/packages/web/src/url-state.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "bun:test"; -import { parseUrlState, serializeUrlState, ALL_COLUMN_IDS } from "./url-state"; +import { describe, expect, it } from "bun:test"; +import { ALL_COLUMN_IDS, parseUrlState, serializeUrlState } from "./url-state"; describe("parseUrlState", () => { it("returns empty defaults when no params", () => { @@ -16,13 +16,17 @@ describe("parseUrlState", () => { }); it("parses sort and order", () => { - const state = parseUrlState(new URLSearchParams("sort=input-cost&order=desc")); + const state = parseUrlState( + new URLSearchParams("sort=input-cost&order=desc"), + ); expect(state.sort).toBe("input-cost"); expect(state.order).toBe("desc"); }); it("parses cols param as array", () => { - const state = parseUrlState(new URLSearchParams("cols=provider,model,input-cost")); + const state = parseUrlState( + new URLSearchParams("cols=provider,model,input-cost"), + ); expect(state.cols).toEqual(["provider", "model", "input-cost"]); }); }); diff --git a/packages/web/src/url-state.ts b/packages/web/src/url-state.ts index 93cff089a..ba0de1e22 100644 --- a/packages/web/src/url-state.ts +++ b/packages/web/src/url-state.ts @@ -1,11 +1,29 @@ export const ALL_COLUMN_IDS = [ - "provider", "model", "family", "provider-id", "model-id", - "tool-call", "reasoning", "input-modalities", "output-modalities", - "input-cost", "output-cost", "reasoning-cost", "cache-read-cost", - "cache-write-cost", "audio-input-cost", "audio-output-cost", - "context-limit", "input-limit", "output-limit", - "structured-output", "temperature", "weights", - "knowledge", "release-date", "last-updated", + "provider", + "model", + "family", + "provider-id", + "model-id", + "tool-call", + "reasoning", + "input-modalities", + "output-modalities", + "input-cost", + "output-cost", + "reasoning-cost", + "cache-read-cost", + "cache-write-cost", + "audio-input-cost", + "audio-output-cost", + "context-limit", + "input-limit", + "output-limit", + "structured-output", + "temperature", + "weights", + "knowledge", + "release-date", + "last-updated", ] as const; export type ColumnId = (typeof ALL_COLUMN_IDS)[number];