diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index e1a0f8fef..85531e36b 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -1,5 +1,6 @@ import z from "zod" import path from "path" +import { rm } from "node:fs/promises" import { Global } from "../../global" import { Filesystem } from "../../util/filesystem" @@ -134,8 +135,18 @@ export namespace AltimateApi { ) } + /** Remove the stored gateway credential file (sign out). No-op if absent. */ + export async function clearCredentials(): Promise { + await rm(credentialsPath(), { force: true }) + } + const VALID_TENANT_REGEX = /^[a-z_][a-z0-9_-]*$/ + /** True if `name` is a well-formed instance/tenant name. */ + export function isValidInstanceName(name: string): boolean { + return VALID_TENANT_REGEX.test(name) + } + /** Validates credentials against the Altimate API. * Mirrors AltimateSettingsHelper.validateSettings from altimate-mcp-engine. */ export async function validateCredentials(creds: { @@ -185,6 +196,35 @@ export namespace AltimateApi { } } + /** + * Exchange a short-lived social `login_token` for the user's gateway + * `auth_token` via POST {altimateUrl}/auth/social/exchange. The token is + * one-time and short-lived, which keeps the raw api_key out of the loopback + * callback URL. Throws on a non-ok response or a missing `auth_token`. + */ + export async function exchangeSocialToken(altimateUrl: string, instance: string, token: string): Promise { + const url = `${altimateUrl.replace(/\/+$/, "")}/auth/social/exchange` + // upstream_fix parity: bound the request so a network stall can't hang the callback. + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 15_000) + const res = await fetch(url, { + method: "POST", + headers: { + "x-tenant": instance, + "Content-Type": "application/json", + }, + body: JSON.stringify({ token }), + signal: controller.signal, + }).finally(() => clearTimeout(timeout)) + if (!res.ok) { + const body = await res.text().catch(() => "") + throw new Error(`Social token exchange failed (${res.status} ${res.statusText}) ${body}`) + } + const data = (await res.json()) as { auth_token?: string } + if (!data.auth_token) throw new Error("Social token exchange did not return an auth_token") + return data.auth_token + } + async function request(creds: AltimateCredentials, method: string, endpoint: string, body?: unknown) { const url = `${creds.altimateUrl}${endpoint}` const res = await fetch(url, { @@ -278,8 +318,7 @@ export namespace AltimateApi { const allIntegrations = await listIntegrations() return integrationIds.map((id) => { const def = allIntegrations.find((i) => i.id === id) - const tools = - def?.tools?.flatMap((t) => (t.enable_all ?? [t.key]).map((k) => ({ key: k }))) ?? [] + const tools = def?.tools?.flatMap((t) => (t.enable_all ?? [t.key]).map((k) => ({ key: k }))) ?? [] return { id, tools } }) } diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index 510f0e1de..af4a40072 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -1,4 +1,154 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { createServer } from "http" +import { randomBytes } from "crypto" +import open from "open" +import { AltimateApi } from "../api/client" + +// Loopback port the CLI listens on for the browser to deliver the gateway +// credential after sign-in. Must match the redirect the web authorize page posts +// back to. 7317 is otherwise unused in this codebase. +const CALLBACK_PORT = 7317 + +// Web app that hosts the signup/login (authorize) page. Overridable for +// dev/staging via ALTIMATE_WEB_URL. +const DEFAULT_WEB_URL = "https://app.myaltimate.com" +// Fallback gateway API base if the callback omits one. +const DEFAULT_API_URL = "https://api.myaltimate.com" + +// Escape reflected values before interpolating them into the callback HTML — the +// error text originates from the URL query string, so it must not be trusted. +function escapeHtml(s: string): string { + return s.replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string, + ) +} + +// Neutral copy: the loopback returns this as soon as it RECEIVES the token, before +// the CLI has exchanged/persisted it — so don't claim "Signed in" (the terminal is +// the source of truth for actual success/failure). +const HTML_SUCCESS = `Altimate Code + +

Authorization received

Return to your terminal to finish connecting.

+` + +const HTML_ERROR = (msg: string) => `Altimate Code + +

Connection failed

${escapeHtml(msg)}

Please return to your terminal and try again.

` + +interface CallbackResult { + api_url: string + instance: string + // Short-lived, one-time login_token delivered by the browser. Exchanged for + // the gateway auth_token in callback() — the raw api_key never rides in the URL. + token: string +} + +interface Pending { + resolve: (creds: CallbackResult) => void + reject: (err: Error) => void +} + +let server: ReturnType | undefined +// Pending flows keyed by the unguessable `state`. Registered synchronously in +// authorize() BEFORE the browser opens, so an instant redirect (an already +// signed-in user) is matched instead of dropped; keying by state also lets two +// concurrent /auth flows coexist without clobbering each other. +const pending = new Map() + +async function startCallbackServer(): Promise { + if (server) return + server = createServer((req, res) => { + const url = new URL(req.url || "/", `http://localhost:${CALLBACK_PORT}`) + if (url.pathname !== "/callback") { + res.writeHead(404) + res.end("Not found") + return + } + + const html = (status: number, body: string) => { + res.writeHead(status, { "Content-Type": "text/html" }) + res.end(body) + } + + // Validate `state` FIRST — before honoring `error` — so a request without a + // known state can neither cancel an in-progress flow nor deliver anything. + const state = url.searchParams.get("state") + const entry = state ? pending.get(state) : undefined + if (!state || !entry) { + html(400, HTML_ERROR("Invalid or unknown sign-in state")) + return + } + pending.delete(state) + + const error = url.searchParams.get("error") + if (error) { + entry.reject(new Error(error)) + html(200, HTML_ERROR(error)) + return + } + + const token = url.searchParams.get("token") + const instance = url.searchParams.get("instance") + const apiUrl = url.searchParams.get("url") || DEFAULT_API_URL + if (!token || !instance) { + const msg = "Missing credential in callback" + entry.reject(new Error(msg)) + html(400, HTML_ERROR(msg)) + return + } + + entry.resolve({ api_url: apiUrl, instance, token }) + html(200, HTML_SUCCESS) + }) + + try { + await new Promise((resolve, reject) => { + // Bind to loopback only — the credential/abort endpoints must not be reachable + // from the LAN. + server!.listen(CALLBACK_PORT, "127.0.0.1", () => resolve()) + server!.on("error", reject) + }) + } catch (err) { + // Reset so a retry isn't blocked by the `if (server) return` guard, and surface + // a clear reason (e.g. the port is already taken) instead of hanging. + server = undefined + const code = (err as NodeJS.ErrnoException)?.code + throw new Error( + code === "EADDRINUSE" + ? `Port ${CALLBACK_PORT} is already in use — close whatever is using it and try again.` + : `Could not start the sign-in server: ${err instanceof Error ? err.message : String(err)}`, + ) + } +} + +function stopCallbackServer() { + if (server) { + server.close() + server = undefined + } +} + +// Register a pending flow keyed by `state` and return its promise. Called +// synchronously in authorize() before the browser opens; the server handler +// resolves/rejects it by state, so a fast redirect is never lost. +function registerPending(state: string, timeoutMs = 5 * 60 * 1000): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (pending.delete(state)) reject(new Error("Timed out waiting for browser sign-in")) + }, timeoutMs) + pending.set(state, { + resolve: (creds) => { + clearTimeout(timeout) + resolve(creds) + }, + reject: (err) => { + clearTimeout(timeout) + reject(err) + }, + }) + }) +} export async function AltimateAuthPlugin(_input: PluginInput): Promise { return { @@ -6,8 +156,74 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { provider: "altimate-backend", methods: [ { + type: "oauth", + label: "Altimate LLM Gateway", + async authorize() { + const state = randomBytes(16).toString("hex") + await startCallbackServer() + // Register the pending flow BEFORE opening the browser so an instant + // redirect can be matched by state rather than dropped as CSRF. + const result = registerPending(state) + // If callback() is never awaited (e.g. the dialog is dismissed before it + // runs), the pending promise still rejects on timeout — swallow that here + // so it can't surface as an unhandled rejection. callback() awaits the + // same promise independently. + void result.catch(() => {}) + + const webUrl = (process.env.ALTIMATE_WEB_URL || DEFAULT_WEB_URL).replace(/\/+$/, "") + // Use 127.0.0.1 to match the loopback bind — a plain `localhost` redirect + // can resolve to ::1 first and hit a closed IPv6 port. + const redirect = `http://127.0.0.1:${CALLBACK_PORT}/callback` + // Land on the sign-up page and let the user choose how to authenticate + // (Google today, more providers later) rather than forcing Google. + const authorizeUrl = + `${webUrl}/register?client=altimate-code` + + `&redirect=${encodeURIComponent(redirect)}` + + `&state=${state}` + + await open(authorizeUrl).catch(() => undefined) + + return { + url: authorizeUrl, + instructions: "Complete sign-in in your browser to connect Altimate LLM Gateway.", + method: "auto", + async callback() { + try { + const creds = await result + // The instance name comes from the browser callback; validate it + // before persisting (the manual paste path validates too). + if (!AltimateApi.isValidInstanceName(creds.instance)) { + throw new Error(`invalid instance name from callback: ${creds.instance}`) + } + // Exchange the short-lived, one-time login_token for the gateway + // auth_token server-side — the raw api_key never rides in the URL. + const authToken = await AltimateApi.exchangeSocialToken(creds.api_url, creds.instance, creds.token) + // Persist to ~/.altimate/altimate.json — the provider loader + // reads this first (it carries the instance/tenant + api_url + // the generic auth.json store can't). + await AltimateApi.saveCredentials({ + altimateUrl: creds.api_url, + altimateInstanceName: creds.instance, + altimateApiKey: authToken, + }) + return { type: "success", key: authToken, provider: "altimate-backend" } + } catch (err) { + // Log the reason (CSRF / timeout / invalid instance / …). Runs in the + // server process, so this goes to the log, not the TUI display. + console.error("[altimate] gateway sign-in failed:", err instanceof Error ? err.message : err) + return { type: "failed" } + } finally { + // Keep the shared server up while another flow is still waiting. + if (pending.size === 0) stopCallbackServer() + } + }, + } + }, + }, + { + // Fallback: paste an instance-name::api-key manually. type: "api", - label: "Connect to Altimate", + label: "Paste API key", }, ], }, diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 8f8dfedb2..87c30f6d5 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -371,6 +371,9 @@ export const ProvidersLoginCommand = effectCmd({ const hooks = yield* pluginSvc.list() const priority: Record = { + // altimate_change start — surface the Altimate LLM Gateway first + "altimate-backend": -1, + // altimate_change end opencode: 0, openai: 1, "github-copilot": 2, diff --git a/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx b/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx index a4bbeb14a..9dc474194 100644 --- a/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx +++ b/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx @@ -81,6 +81,20 @@ function show(api: TuiPluginApi) { api.ui.dialog.replace(() => ) } +// altimate_change start — /logout: clear the stored gateway credential. Self-contained +// (dispatched from the packages/tui slash command in app.tsx) since AltimateApi is +// opencode-side and unreachable from packages/tui. +async function logout(api: TuiPluginApi) { + try { + await AltimateApi.clearCredentials() + await api.client.instance.dispose() + api.ui.toast({ variant: "success", message: "Signed out of Altimate LLM Gateway" }) + } catch (err) { + api.ui.toast({ variant: "error", message: err instanceof Error ? err.message : "Sign-out failed" }) + } +} +// altimate_change end + const tui: TuiPlugin = async (api) => { api.keymap.registerLayer({ commands: [ @@ -93,8 +107,22 @@ const tui: TuiPlugin = async (api) => { show(api) }, }, + // altimate_change start — /logout entry point, dispatched by packages/tui/src/app.tsx + { + name: "altimate.provider.logout", + title: "Sign out of Altimate LLM Gateway", + category: "Altimate", + namespace: "palette", + run() { + void logout(api) + }, + }, + // altimate_change end ], - bindings: api.tuiConfig.keybinds.gather("altimate.palette", ["altimate.provider.connect"]), + bindings: api.tuiConfig.keybinds.gather("altimate.palette", [ + "altimate.provider.connect", + "altimate.provider.logout", + ]), }) } diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index abb2a2d64..4cbf05ade 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -24,11 +24,14 @@ import { onCleanup, batch, Show, - on, } from "solid-js" import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" -import { DialogProvider as DialogProviderList } from "./component/dialog-provider" +// altimate_change start — /auth (gateway sign-in) + /connect (curated welcome picker) +// + /logout commands +import { DialogAltimateAuth } from "./component/dialog-provider" +import { DialogModelWelcome, useReady, resetSetupComplete } from "./component/altimate-onboarding" +// altimate_change end import { ErrorComponent } from "./component/error-component" import { PluginRouteMissing } from "./component/plugin-route-missing" import { ProjectProvider, useProject } from "./context/project" @@ -535,18 +538,17 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }) }) - createEffect( - on( - () => sync.status === "complete" && sync.data.provider.length === 0, - (isEmpty, wasEmpty) => { - // only trigger when we transition into an empty-provider state - if (!isEmpty || wasEmpty) return - dialog.replace(() => ) - }, - ), - ) + // altimate_change — first run is now a welcoming home-screen panel (see + // component/welcome-panel.tsx, wired in routes/home.tsx), not an auto-opened modal. + // The panel's readiness-aware tips guide the user to run /connect, which opens the + // curated DialogModelWelcome picker. const connected = useConnected() + // altimate_change — onboarding readiness (real credentials OR a completed first-run + // setup pick, e.g. Big Pickle) gates first-run chat/tips; see + // component/altimate-onboarding.tsx. Named distinctly from the plugin-host `ready` + // signal above (line ~408), which tracks TUI plugin startup, not onboarding state. + const onboardingReady = useReady() const currentWorktreeWorkspace = createMemo(() => { const workspaceID = project.workspace.current() if (!workspaceID) return @@ -735,14 +737,43 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, { name: "provider.connect", - title: "Connect provider", + title: "Connect to your AI model provider", suggested: !connected(), slashName: "connect", run: () => { - dialog.replace(() => ) + // altimate_change — curated welcome picker (Gateway + top BYOK providers + + // Big Pickle) instead of the full provider list; "Search all providers…" + // still hands off to the full DialogModel catalog. + dialog.replace(() => ) }, category: "Provider", }, + // altimate_change start — /auth: sign in to the Altimate LLM Gateway directly; + // /logout: clear the stored gateway credential and disconnect. + { + name: "altimate.auth", + title: "Sign in to Altimate LLM Gateway", + suggested: !connected(), + slashName: "auth", + run: () => { + dialog.replace(() => ) + }, + category: "Provider", + }, + { + name: "altimate.logout", + title: "Sign out of Altimate LLM Gateway", + slashName: "logout", + run: () => { + // altimate_change — the credential clear + instance dispose + toast happen + // opencode-side (AltimateApi is unreachable from packages/tui); see + // packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx. + keymap.dispatchCommand("altimate.provider.logout") + resetSetupComplete() + }, + category: "Provider", + }, + // altimate_change end ...(sync.data.console_state.switchableOrgCount > 1 ? [ { @@ -1015,6 +1046,9 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // altimate_change start — upstream_fix: persist update availability for footer indicator kv.set(UPGRADE_KV_KEY, version) // altimate_change end + // altimate_change — don't cover the first-run welcome panel with the update confirm + // dialog; the footer upgrade indicator still surfaces it. Prompt once a model is ready. + if (!onboardingReady()) return const skipped = kv.get("skipped_version") if (skipped && !isVersionGreater(version, skipped)) return diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx new file mode 100644 index 000000000..5a29e8378 --- /dev/null +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -0,0 +1,316 @@ +// Altimate onboarding layer — kept in a dedicated, altimate-owned file so it does +// NOT enlarge the rebase surface of the upstream `dialog-model.tsx`. Holds the +// first-run readiness state, the curated welcome/provider picker, and the Big +// Pickle interstitial. Imports back into dialog-model are runtime-only (used inside +// callbacks/JSX), so the circular reference is safe. +import { createMemo, createSignal, For, Show, onMount } from "solid-js" +import { useLocal } from "../context/local" +import { useDialog } from "../ui/dialog" +import { useTheme, selectedForeground } from "../context/theme" +import { TextAttributes, RGBA } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { createDialogProviderOptions } from "./dialog-provider" +import { DialogModel } from "./dialog-model" +import { useConnected } from "./use-connected" + +// Session-scoped "setup complete" flag. Set when the user picks a ready model, +// chooses the free Big Pickle option, or finishes the gateway flow. Combined with +// useConnected() (real credentials) via useReady(), it gates the first-run chat +// lock. Module-global so it is shared across the app and resets on every process +// launch (so a fresh relaunch is a clean fresh-user state). +const [setupComplete, setSetupComplete] = createSignal(false) +export function markSetupComplete() { + setSetupComplete(true) +} +// Cleared on /logout so first-run tips don't keep showing "you're all set" after +// the credential is gone. +export function resetSetupComplete() { + setSetupComplete(false) +} +export function useReady() { + const connected = useConnected() + return createMemo(() => connected() || setupComplete()) +} + +// First-run welcome picker (presentation only; reuses the same action handlers as +// DialogModel/createDialogProviderOptions). A curated six: five recommended +// providers + a "Search all providers…" row that hands off to the full DialogModel +// picker. The long tail stays behind search. +const NAME_W = 24 +type WelcomeTone = "success" | "warning" | "muted" + +interface WelcomeRow { + name: string + note: string + tone: WelcomeTone + activate: () => void + // Identifies the row for the "currently selected" tick. providerID alone matches + // any model of that provider; add modelID to match a specific model (Big Pickle). + providerID?: string + modelID?: string +} + +export function DialogModelWelcome(props: { intro?: string }) { + const { theme } = useTheme() + const dialog = useDialog() + const local = useLocal() + const providers = createDialogProviderOptions() + const [selected, setSelected] = createSignal(0) + + onMount(() => dialog.setSize("large")) + + function connectProvider(id: string) { + // Reuse the exact provider onSelect (gateway flow for altimate-backend, + // auth-method screens for the BYOK providers). + providers() + .find((o) => o.value === id) + ?.onSelect?.() + } + + function chooseBigPickle() { + dialog.replace(() => ) + } + + function openFullCatalog() { + dialog.replace(() => ) + } + + const rows = createMemo(() => [ + { + name: "Altimate LLM Gateway", + note: "Recommended · best tool-calling · 10M free tokens", + tone: "success", + providerID: "altimate-backend", + activate: () => connectProvider("altimate-backend"), + }, + { + name: "Anthropic (Claude)", + note: "bring your own API key", + tone: "muted", + providerID: "anthropic", + activate: () => connectProvider("anthropic"), + }, + { + name: "OpenAI (GPT)", + note: "bring your own API key", + tone: "muted", + providerID: "openai", + activate: () => connectProvider("openai"), + }, + { + name: "Google (Gemini)", + note: "bring your own API key", + tone: "muted", + providerID: "google", + activate: () => connectProvider("google"), + }, + { + name: "Big Pickle", + note: "free, no signup — slower, unreliable tool-calling", + tone: "warning", + providerID: "opencode", + modelID: "big-pickle", + activate: chooseBigPickle, + }, + { name: "Search all providers…", note: "/", tone: "muted", activate: openFullCatalog }, + ]) + + // The currently active model → drives the green "selected" tick. + const current = createMemo(() => local.model.current()) + const isCurrent = (row: WelcomeRow) => { + const c = current() + if (!row.providerID || !c || c.providerID !== row.providerID) return false + return row.modelID ? c.modelID === row.modelID : true + } + + // Indices 0-4 are providers, 5 is the search row (rendered below a divider). + const COUNT = 6 + function move(direction: number) { + setSelected((prev) => (prev + direction + COUNT) % COUNT) + } + + useKeyboard((evt) => { + if (evt.name === "up" || (evt.ctrl && evt.name === "p")) return move(-1) + if (evt.name === "down" || (evt.ctrl && evt.name === "n")) return move(1) + if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() + rows()[selected()].activate() + return + } + // "/", ctrl+a, or any letter/number reveals the full searchable catalog. + if (evt.name === "/" || (evt.ctrl && evt.name === "a") || /^[a-z0-9]$/i.test(evt.name ?? "")) { + evt.preventDefault() + openFullCatalog() + } + }) + + const selFg = selectedForeground(theme) + const transparent = RGBA.fromInts(0, 0, 0, 0) + const noteColor = (tone: WelcomeTone) => + tone === "success" ? theme.success : tone === "warning" ? theme.warning : theme.textMuted + + const Row = (props: { row: WelcomeRow; index: number }) => { + const active = createMemo(() => selected() === props.index) + return ( + setSelected(props.index)} + onMouseUp={() => props.row.activate()} + > + + {active() ? "›" : " "} + + + + {props.row.name} + + + {/* bright green so it reads clearly even where ANSI green renders dim */} + + {isCurrent(props.row) ? "✓" : " "} + + + {isCurrent(props.row) ? `${props.row.note} · selected` : props.row.note} + + + ) + } + + return ( + + + + {props.intro} + + + + + + Select a provider + + — you can change this anytime with /model + + + {(row, i) => } + + + + + + ) +} + +// Big Pickle interstitial — one confirm, default No. Custom component (not +// DialogSelect) so the full warning wraps instead of clipping; y/n keys work, +// enter accepts the highlighted row (No by default). +export function DialogBigPickleConfirm(props: { origin: "welcome" | "model" }) { + const { theme } = useTheme() + const dialog = useDialog() + const local = useLocal() + const [selected, setSelected] = createSignal(0) // 0 = No (default) + + function no() { + dialog.replace(() => (props.origin === "welcome" ? : )) + } + function yes() { + dialog.clear() + local.model.set({ providerID: "opencode", modelID: "big-pickle" }, { recent: true }) + markSetupComplete() + } + const options = [ + { label: "No — pick something else", hint: "(default)", run: no }, + { label: "Yes — continue with Big Pickle", hint: "", run: yes }, + ] + + useKeyboard((evt) => { + if (evt.name === "up" || evt.name === "down") { + setSelected((prev) => (prev + 1) % 2) + evt.preventDefault() + return + } + if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() + options[selected()].run() + return + } + if (evt.name === "y" && !evt.ctrl && !evt.meta) { + evt.preventDefault() + yes() + return + } + if (evt.name === "n" && !evt.ctrl && !evt.meta) { + evt.preventDefault() + no() + } + }) + + const selFg = selectedForeground(theme) + const transparent = RGBA.fromInts(0, 0, 0, 0) + + return ( + + + + Use Big Pickle? + + dialog.clear()}> + esc + + + + Big Pickle works for chat but often fails at data tasks. The Gateway is free to start (10M tokens). Continue? + [y/N] + + + + {(option, index) => ( + setSelected(index())} onMouseUp={() => option.run()}> + + {selected() === index() ? "›" : " "} + + + + {option.label} + + + + {option.hint} + + + )} + + + + ) +} diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index ff8715ef8..9b5027ff4 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -1,14 +1,23 @@ import { createMemo, createSignal } from "solid-js" import { useLocal } from "../context/local" -import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda" +import { useSync } from "../context/sync" +import { map, pipe, flatMap, entries, filter, sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" -import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" +import { createDialogProviderOptions, DialogProvider, WARNLIST, PROVIDER_PRIORITY } from "./dialog-provider" import { DialogVariant } from "./dialog-variant" import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" -import { useSync } from "../context/sync" +// altimate_change — onboarding helpers (readiness state, welcome picker, Big Pickle +// interstitial) live in the altimate-owned ./altimate-onboarding to keep this +// upstream file's rebase surface small. markSetupComplete / DialogBigPickleConfirm +// are used by the restructured DialogModel below. +import { markSetupComplete, DialogBigPickleConfirm } from "./altimate-onboarding" +// altimate_change start — DialogModel restructured from the upstream flat +// favorites/recent/provider list into READY / NEEDS-SETUP sections with a Big Pickle +// fallback. This is an in-place rewrite of the upstream component; on an upstream +// merge, expect a conflict here and re-apply the READY/NEEDS-SETUP shaping. export function DialogModel(props: { providerID?: string }) { const local = useLocal() const sync = useSync() @@ -18,112 +27,98 @@ export function DialogModel(props: { providerID?: string }) { const connected = useConnected() const providers = createDialogProviderOptions() - const showExtra = createMemo(() => connected() && !props.providerID) + // A provider is "ready" (usable now) when it has valid credentials: it is present + // in the live provider list with at least one model — and, for the free OpenCode + // provider, with at least one paid model (a Zen key entered). + function providerReady(id: string) { + const p = sync.data.provider.find((x) => x.id === id) + if (!p) return false + if (id === "opencode") return Object.values(p.models).some((m) => m.cost?.input != null && m.cost.input !== 0) + return Object.keys(p.models).length > 0 + } const options = createMemo(() => { const needle = query().trim() - const showSections = showExtra() && needle.length === 0 - const favorites = connected() ? local.model.favorite() : [] - const recents = local.model.recent() - - function toOptions(items: typeof favorites, category: string) { - if (!showSections) return [] - return items.flatMap((item) => { - const provider = sync.data.provider.find((provider) => provider.id === item.providerID) - if (!provider) return [] - const model = provider.models[item.modelID] - if (!model) return [] - return [ - { - key: item, - value: { providerID: provider.id, modelID: model.id }, - title: model.name ?? item.modelID, - description: provider.name, - category, - disabled: provider.id === "opencode" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect: () => { - onSelect(provider.id, model.id) - }, - }, - ] - }) - } + const favorites = local.model.favorite() - const favoriteOptions = toOptions(favorites, "Favorites") - const recentOptions = toOptions( - recents.filter( - (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), - ), - "Recent", - ) - - const providerOptions = pipe( + // READY — models from providers that already have valid credentials. Selecting + // one switches instantly. + const readyOptions = pipe( sync.data.provider, - sortBy( - (provider) => provider.id !== "opencode", - (provider) => provider.name, - ), + filter((provider) => providerReady(provider.id)), + // altimate_change — order ready providers by the same PROVIDER_PRIORITY as the + // welcome/NEEDS-SETUP lists so the Altimate LLM Gateway leads the full list too. + sortBy((provider) => PROVIDER_PRIORITY[provider.id] ?? 99), flatMap((provider) => pipe( provider.models, entries(), filter(([_, info]) => info.status !== "deprecated"), filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), - map(([model, info]) => ({ - value: { providerID: provider.id, modelID: model }, - title: info.name ?? model, - releaseDate: info.release_date, - description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) - ? "(Favorite)" - : undefined, - category: connected() ? provider.name : undefined, - disabled: provider.id === "opencode" && model.includes("-nano"), - footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect() { - onSelect(provider.id, model) - }, - })), - filter((option) => { - if (!showSections) return true - if ( - favorites.some( - (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID, - ) - ) - return false - if ( - recents.some( - (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID, - ) - ) - return false - return true + map(([modelID, info]) => { + const warn = WARNLIST[modelID] + const isFav = favorites.some((f) => f.providerID === provider.id && f.modelID === modelID) + return { + value: { providerID: provider.id, modelID } as { providerID: string; modelID: string } | string, + title: info.name ?? modelID, + description: warn ? `${provider.name} · ${warn}` : provider.name, + category: "READY", + footer: isFav ? "★" : undefined, + onSelect() { + // altimate_change — go through the shared onSelect(providerID, modelID) + // helper so a ready pick also honors the model-variant follow-up flow, + // and mark setup complete so the first-run chat lock lifts. + onSelect(provider.id, modelID) + markSetupComplete() + }, + } }), - (options) => sortModelOptions(options, props.providerID !== undefined), + sortBy((x) => x.title), ), ), ) - const popularProviders = !connected() - ? pipe( - providers(), - map((option) => ({ - ...option, - category: "Popular providers", - })), - take(6), - ) - : [] + // NEEDS SETUP — providers without valid credentials (selecting routes into their + // auth flow first), plus the free Big Pickle option. Hidden when scoped to one + // provider (post-connect model list). + const setupOptions = props.providerID + ? [] + : (() => { + const list = providers() + .filter((o) => !providerReady(o.value)) + .map((o) => ({ + value: o.value as { providerID: string; modelID: string } | string, + title: o.title, + description: o.description, + category: "NEEDS SETUP", + footer: undefined as string | undefined, + onSelect: o.onSelect, + })) + const bigPickle = { + value: "big-pickle" as { providerID: string; modelID: string } | string, + title: "Big Pickle", + description: "free, no signup — slower, unreliable tool-calling", + category: "NEEDS SETUP", + footer: undefined as string | undefined, + async onSelect() { + dialog.replace(() => ) + }, + } + // Big Pickle sits at priority 4 — just above OpenCode Zen (priority 5). + const zenIdx = list.findIndex((o) => o.value === "opencode") + if (zenIdx === -1) list.push(bigPickle) + else list.splice(zenIdx, 0, bigPickle) + return list + })() if (needle) { return [ - ...fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj), - ...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj), + ...fuzzysort.go(needle, readyOptions, { keys: ["title", "description"] }).map((x) => x.obj), + ...fuzzysort.go(needle, setupOptions, { keys: ["title", "description"] }).map((x) => x.obj), ] } - return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders] + return [...readyOptions, ...setupOptions] }) const provider = createMemo(() => @@ -167,6 +162,9 @@ export function DialogModel(props: { providerID?: string }) { title: "Favorite", hidden: !connected(), onTrigger: (option) => { + // altimate_change — NEEDS-SETUP rows carry plain string values (provider + // ids / "big-pickle"); only real {providerID, modelID} rows are favoritable. + if (typeof option.value === "string") return local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) }, }, @@ -179,7 +177,11 @@ export function DialogModel(props: { providerID?: string }) { /> ) } +// altimate_change end +// altimate_change — kept for packages/tui/test/cli/cmd/tui/model-options.test.ts; +// no longer used internally by DialogModel above (see READY/NEEDS-SETUP shaping), +// but preserved as a public export so the existing upstream test keeps passing. export function sortModelOptions( options: T[], newestFirst: boolean, diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index e9f6ca056..a19c1ab44 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, onMount, Show } from "solid-js" +import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import { useSync } from "../context/sync" import { map, pipe, sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" @@ -15,15 +15,33 @@ import { isConsoleManagedProvider } from "../util/provider-origin" import { useConnected } from "./use-connected" import { useBindings, useOpencodeKeymap } from "../keymap" import { useClipboard } from "../context/clipboard" +import { useLocal } from "../context/local" +// altimate_change — mark first-run setup complete once the gateway sign-in succeeds +// (used by AutoMethod below); flips useReady() so the first-run chat lock lifts. +import { markSetupComplete } from "./altimate-onboarding" -const PROVIDER_PRIORITY: Record = { - opencode: 0, - "opencode-go": 1, +export const PROVIDER_PRIORITY: Record = { + // altimate_change start — Part 1 onboarding: Altimate LLM Gateway is the + // recommended default first; the BYOK providers rank next; OpenCode Zen loses + // its "Recommended" tag and drops below. (Big Pickle occupies priority 4, injected + // by dialog-model between Google and Zen.) + "altimate-backend": 0, + anthropic: 1, openai: 2, - "github-copilot": 3, - anthropic: 4, - google: 5, + google: 3, + // 4 reserved for Big Pickle (see dialog-model) + opencode: 5, + "opencode-go": 6, + "github-copilot": 7, + // altimate_change end +} + +// altimate_change start — known-bad tool-callers, surfaced inline in the model picker +// (imported by dialog-model's READY/NEEDS-SETUP list). +export const WARNLIST: Record = { + "qwen-plus": "⚠ known tool-calling issues", } +// altimate_change end const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__" const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ @@ -55,15 +73,19 @@ export function providerOptions(list: { id: string; name: string }[]): ProviderO ), map((provider) => ({ type: "provider" as const, - title: provider.name, + // altimate_change start — brand the gateway entry + relabel priorities + title: provider.id === "altimate-backend" ? "Altimate LLM Gateway" : provider.name, value: provider.id, providerID: provider.id, description: { - opencode: "(Recommended)", + "altimate-backend": "Recommended · best tool-calling · 10M free tokens", anthropic: "(API key)", openai: "(ChatGPT Plus/Pro or API key)", + google: "(API key)", + opencode: "Bring your own Zen key", "opencode-go": "Low cost subscription for everyone", }[provider.id], + // altimate_change end category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers", })), ), @@ -147,12 +169,6 @@ export function createDialogProviderOptions() { gutter: connected && onboarded() ? () => : undefined, async onSelect() { if (consoleManaged) return - // altimate_change start — restore Altimate credential validation/save/model-picker flow - if (providerID === "altimate-backend") { - keymap.dispatchCommand("altimate.provider.connect") - return - } - // altimate_change end const methods = sync.data.provider_auth[providerID] ?? [ { @@ -191,31 +207,58 @@ export function createDialogProviderOptions() { inputs = value } - const result = await sdk.client.provider.oauth.authorize({ - providerID, - method: index, - inputs, - }) - if (result.error) { - toast.show({ - variant: "error", - message: JSON.stringify(result.error), + // altimate_change — guard the authorize (e.g. loopback port busy) so the + // recommended /connect path surfaces the error instead of failing silently + // (parity with DialogAltimateAuth). + try { + const result = await sdk.client.provider.oauth.authorize({ + providerID, + method: index, + inputs, }) + if (result.error) { + toast.show({ + variant: "error", + message: JSON.stringify(result.error), + }) + dialog.clear() + return + } + if (result.data?.method === "code") { + dialog.replace(() => ( + + )) + } else if (result.data?.method === "auto") { + dialog.replace(() => ( + + )) + } else { + dialog.clear() + } + } catch (err) { + toast.error(err instanceof Error ? err : new Error("Failed to start sign-in")) dialog.clear() - return - } - if (result.data?.method === "code") { - dialog.replace(() => ( - - )) - } - if (result.data?.method === "auto") { - dialog.replace(() => ( - - )) } } if (method.type === "api") { + // altimate_change start — restore Altimate credential validation/save/model-picker + // flow: the instance-name::api-key entry is opencode-side (needs AltimateApi), so + // it's re-homed as a plugin (see docs/internal/2026-06-23-tui-fork-features-as-plugins-adr.md). + if (providerID === "altimate-backend") { + keymap.dispatchCommand("altimate.provider.connect") + return + } + // altimate_change end let metadata: Record | undefined if (method.prompts?.length) { const value = await PromptsMethod({ dialog, prompts: method.prompts }) @@ -239,6 +282,52 @@ export function DialogProvider() { return } +// altimate_change start — /auth entry: go straight to the Altimate LLM Gateway +// sign-in (the OAuth loopback method, index 0), skipping the provider picker. +export function DialogAltimateAuth() { + const { theme } = useTheme() + const sdk = useSDK() + const dialog = useDialog() + const toast = useToast() + + onMount(async () => { + const providerID = "altimate-backend" + try { + const result = await sdk.client.provider.oauth.authorize({ providerID, method: 0 }) + if (result.error) { + toast.show({ variant: "error", message: JSON.stringify(result.error) }) + dialog.clear() + return + } + if (result.data?.method === "auto") { + dialog.replace(() => ( + + )) + } else if (result.data?.method === "code") { + dialog.replace(() => ( + + )) + } else { + dialog.clear() + } + } catch (err) { + // e.g. the loopback port is busy — don't hang on "Starting sign-in…". + toast.error(err instanceof Error ? err : new Error("Failed to start sign-in")) + dialog.clear() + } + }) + + return ( + + + Altimate LLM Gateway + + Starting sign-in… + + ) +} +// altimate_change end + interface AutoMethodProps { index: number providerID: string @@ -250,25 +339,38 @@ function AutoMethod(props: AutoMethodProps) { const sdk = useSDK() const dialog = useDialog() const sync = useSync() + const local = useLocal() const toast = useToast() const clipboard = useClipboard() + // altimate_change — success state: confirm inline (green) below the "waiting" line, + // then auto-close, instead of jumping into the model picker. + const [connected, setConnected] = createSignal(false) + // Guard against a late callback / auto-close firing after the dialog is dismissed. + let disposed = false + let closeTimer: ReturnType | undefined + onCleanup(() => { + disposed = true + if (closeTimer) clearTimeout(closeTimer) + }) useBindings(() => ({ - bindings: [ - { - key: "c", - desc: "Copy provider code", - group: "Dialog", - cmd: () => { - const code = - props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url - clipboard - .write?.(code) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) - }, - }, - ], + bindings: connected() + ? [] + : [ + { + key: "c", + desc: "Copy provider code", + group: "Dialog", + cmd: () => { + const code = + props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url + clipboard + .write?.(code) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + }, + }, + ], })) onMount(async () => { @@ -276,7 +378,10 @@ function AutoMethod(props: AutoMethodProps) { providerID: props.providerID, method: props.index, }) + if (disposed) return if (result.error) { + // altimate_change — surface the failure instead of clearing silently. The + // precise reason is also logged server-side by the plugin callback. toast.show({ variant: "error", message: @@ -289,6 +394,36 @@ function AutoMethod(props: AutoMethodProps) { } await sdk.client.instance.dispose() await sync.bootstrap() + if (disposed) return + // altimate_change start — mark setup complete (flips useReady → unlocks first-run chat/tips) + markSetupComplete() + // The gateway sign-in already shows the auth URL + "Waiting for authorization…". + // On success, confirm inline (green) and auto-close after a moment rather than + // opening the model picker. Auto-select a model so the user can chat right away. + if (props.providerID === "altimate-backend") { + const provider = sync.data.provider.find((p) => p.id === props.providerID) + const model = provider + ? Object.entries(provider.models).find(([, info]) => info.status !== "deprecated")?.[0] + : undefined + if (!model) { + // Connected, but nothing usable to select — don't fake a green ✓; open the + // picker so the user can choose a model (or another provider). + toast.show({ + message: "Connected, but no model is available yet — pick one to start.", + variant: "warning", + }) + dialog.replace(() => ) + return + } + local.model.set({ providerID: props.providerID, modelID: model }, { recent: true }) + setConnected(true) + closeTimer = setTimeout(() => { + if (!disposed) dialog.clear() + }, 5000) + return + } + // altimate_change end + toast.show({ message: `Connected to ${props.title}`, variant: "success" }) dialog.replace(() => ) }) @@ -298,18 +433,35 @@ function AutoMethod(props: AutoMethodProps) { {props.title} - dialog.clear()}> - esc - + + dialog.clear()}> + esc + + {props.authorization.instructions} - Waiting for authorization... - - c copy - + {/* altimate_change — swap the "waiting" line for a green success confirmation */} + + Waiting for authorization... + + c copy + + + } + > + {/* theme.success is plain ANSI green (col 2) — dim/gray in many palettes; + diffHighlightAdded is the bright green (greenBright) so it reads clearly. */} + + ✓ Authentication successful + + You are all set — returning to Altimate Code… + ) } diff --git a/packages/tui/src/component/use-connected.tsx b/packages/tui/src/component/use-connected.tsx index f59e95c05..c4644ca57 100644 --- a/packages/tui/src/component/use-connected.tsx +++ b/packages/tui/src/component/use-connected.tsx @@ -6,7 +6,10 @@ export function useConnected() { return createMemo(() => sync.data.provider.some( (provider) => - provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost?.input !== 0), + provider.id !== "opencode" || + // altimate_change — treat undefined cost as "not paid" (upstream `!== 0` was + // true for undefined, mislabeling OpenCode as connected without a Zen key). + Object.values(provider.models).some((model) => model.cost?.input != null && model.cost.input !== 0), ), ) } diff --git a/packages/tui/src/component/welcome-panel.tsx b/packages/tui/src/component/welcome-panel.tsx new file mode 100644 index 000000000..0038f43bb --- /dev/null +++ b/packages/tui/src/component/welcome-panel.tsx @@ -0,0 +1,102 @@ +import { Show } from "solid-js" +import { TextAttributes } from "@opentui/core" +import { useTheme } from "../context/theme" +import { Logo } from "./logo" +import { useReady } from "./altimate-onboarding" +import { InstallationVersion } from "@opencode-ai/core/installation/version" + +// altimate_change — Claude-Code-style full-width boot box: big block wordmark on +// the left, a "Tips for getting started" section (readiness-aware: /connect → +// /discover) and a "What is Altimate Code" section on the right. Shared between +// the home route and the session view so the header stays consistent when a +// command (e.g. /discover) starts a session. zIndex keeps it above transient top +// toasts (update/MCP) that would otherwise blank its top rows. +export function WelcomePanel() { + const { theme } = useTheme() + const ready = useReady() + return ( + + {/* left column — the block-letter wordmark (59 cols wide) */} + + + Welcome to Altimate Code + + + + {/* right column — tips + what-is sections */} + + + + Tips for getting started + + + Run + /connect + + {" "} + to pick your AI model provider — 75+ providers supported · Altimate LLM Gateway recommended (10M free + tokens) + + + } + > + + Now connect your warehouse or dbt project — run + /discover + + {" "} + to detect your data stack, then just say what you want to do + + + + + + + + What is Altimate Code + + + The intelligence layer for data engineering AI — 100+ deterministic tools for SQL analysis, column-level + lineage, dbt, FinOps, and warehouse connectivity across every major cloud platform. + + + Run standalone in your terminal, embed underneath Claude Code or Codex, or integrate into CI pipelines and + orchestration DAGs. Precision data tooling for any LLM. + + + + + ) +} diff --git a/packages/tui/src/feature-plugins/home/tips.tsx b/packages/tui/src/feature-plugins/home/tips.tsx index 620f94a63..14950f0db 100644 --- a/packages/tui/src/feature-plugins/home/tips.tsx +++ b/packages/tui/src/feature-plugins/home/tips.tsx @@ -45,9 +45,13 @@ const tui: TuiPlugin = async (api) => { // altimate_change start — upstream_fix: wait for synced session count before first-run onboarding const first = createMemo(() => api.state.ready && api.state.session.count() === 0) // altimate_change end + // altimate_change — treat undefined cost as "not paid" (upstream `!== 0` was + // true for undefined, mislabeling OpenCode as connected without a Zen key). const connected = createMemo(() => api.state.provider.some( - (item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0), + (item) => + item.id !== "opencode" || + Object.values(item.models).some((model) => model.cost?.input != null && model.cost.input !== 0), ), ) // altimate_change start — upstream_fix: restore first-run onboarding state diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index faaaf635a..d0926bec8 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -1,6 +1,5 @@ import { Prompt, type PromptRef } from "../component/prompt" import { createEffect, createMemo, createSignal, onMount } from "solid-js" -import { Logo } from "../component/logo" import { useSync } from "../context/sync" import { Toast } from "../ui/toast" import { useArgs } from "../context/args" @@ -15,6 +14,12 @@ import { HomeSessionDestinationProvider } from "./home/session-destination" // altimate_change start — upstream_fix: restore first-run home onboarding hint import { useTheme } from "../context/theme" // altimate_change end +// altimate_change start — Part 1 onboarding: the boot box is now the readiness-aware +// WelcomePanel (replaces the bare Logo default in the home_logo slot); superseded the +// one-line "Get started: /connect ... /discover ..." hint below, which duplicated the +// same guidance the panel's "Tips for getting started" section now covers. +import { WelcomePanel } from "../component/welcome-panel" +// altimate_change end let once = false const placeholder = { @@ -62,14 +67,6 @@ export function Home() { if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7)) return configured ?? 75 }) - // altimate_change start — upstream_fix: restore first-run home onboarding state - const connected = createMemo(() => - sync.data.provider.some( - (item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0), - ), - ) - const isFirstTimeUser = createMemo(() => sync.ready && sync.data.session.length === 0 && !connected()) - // altimate_change end let sent = false onMount(() => { @@ -105,22 +102,22 @@ export function Home() { return ( - - - + {/* altimate_change start — boot box always shows on home (its "Tips for getting + started" section is readiness-aware, see WelcomePanel), replacing the plain + Logo default for the home_logo slot. */} + + - + - + + {/* altimate_change end */} } placeholders={placeholder} /> - {/* altimate_change start — upstream_fix: restore first-run home onboarding hint */} - - {/* altimate_change end */} diff --git a/packages/tui/test/cli/cmd/tui/provider-options.test.ts b/packages/tui/test/cli/cmd/tui/provider-options.test.ts index 36cc7b6ee..970b1d230 100644 --- a/packages/tui/test/cli/cmd/tui/provider-options.test.ts +++ b/packages/tui/test/cli/cmd/tui/provider-options.test.ts @@ -15,6 +15,9 @@ describe("providerOptions", () => { }) test("keeps popular providers first and sorts the rest alphabetically", () => { + // altimate_change — PROVIDER_PRIORITY ranks the Altimate LLM Gateway first, then + // anthropic ahead of openai (see component/dialog-provider.tsx); updated from the + // upstream fixture's openai-before-anthropic expectation to match. expect( providerOptions([ { id: "openai", name: "OpenAI" }, @@ -23,7 +26,7 @@ describe("providerOptions", () => { { id: "mistral", name: "Mistral" }, { id: "aws", name: "AWS Bedrock" }, ]).map((option) => option.value), - ).toEqual(["openai", "anthropic", "aws", "mistral", "custom-z", "__opencode_custom_provider__"]) + ).toEqual(["anthropic", "openai", "aws", "mistral", "custom-z", "__opencode_custom_provider__"]) }) test("does not collide with a configured provider named other", () => {