diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts
index 510f0e1de..aefdd7b99 100644
--- a/packages/opencode/src/altimate/plugin/altimate.ts
+++ b/packages/opencode/src/altimate/plugin/altimate.ts
@@ -1,4 +1,129 @@
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 Google 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"
+
+const HTML_SUCCESS = `
Altimate Code
+
+Signed in ✓ You can return to your terminal.
+`
+
+const HTML_ERROR = (msg: string) => `Altimate Code
+
+Connection failed ${msg}
Please return to your terminal and try again.
`
+
+interface CallbackResult {
+ api_url: string
+ instance: string
+ api_key: string
+}
+
+interface Pending {
+ state: string
+ resolve: (creds: CallbackResult) => void
+ reject: (err: Error) => void
+}
+
+let server: ReturnType | undefined
+let pending: Pending | undefined
+
+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)
+ }
+
+ const error = url.searchParams.get("error")
+ if (error) {
+ pending?.reject(new Error(error))
+ pending = undefined
+ html(200, HTML_ERROR(error))
+ return
+ }
+
+ const state = url.searchParams.get("state")
+ // Bind the callback to the unguessable state the CLI generated — rejects a
+ // stray/malicious local request that didn't originate from our browser tab.
+ if (!pending || !state || state !== pending.state) {
+ const msg = "Invalid state — possible CSRF"
+ pending?.reject(new Error(msg))
+ pending = undefined
+ html(400, HTML_ERROR(msg))
+ return
+ }
+
+ const apiKey = url.searchParams.get("key")
+ const instance = url.searchParams.get("instance")
+ const apiUrl = url.searchParams.get("url") || DEFAULT_API_URL
+ if (!apiKey || !instance) {
+ const msg = "Missing credential in callback"
+ pending.reject(new Error(msg))
+ pending = undefined
+ html(400, HTML_ERROR(msg))
+ return
+ }
+
+ const current = pending
+ pending = undefined
+ current.resolve({ api_url: apiUrl, instance, api_key: apiKey })
+ html(200, HTML_SUCCESS)
+ })
+
+ await new Promise((resolve, reject) => {
+ server!.listen(CALLBACK_PORT, () => resolve())
+ server!.on("error", reject)
+ })
+}
+
+function stopCallbackServer() {
+ if (server) {
+ server.close()
+ server = undefined
+ }
+}
+
+function waitForCallback(state: string, timeoutMs = 5 * 60 * 1000): Promise {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ if (pending) {
+ pending = undefined
+ reject(new Error("Timed out waiting for browser sign-in"))
+ }
+ }, timeoutMs)
+ pending = {
+ state,
+ resolve: (creds) => {
+ clearTimeout(timeout)
+ resolve(creds)
+ },
+ reject: (err) => {
+ clearTimeout(timeout)
+ reject(err)
+ },
+ }
+ })
+}
export async function AltimateAuthPlugin(_input: PluginInput): Promise {
return {
@@ -6,8 +131,54 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise {
provider: "altimate-backend",
methods: [
{
+ type: "oauth",
+ label: "Altimate LLM Gateway",
+ async authorize() {
+ // Bind the port BEFORE opening the browser so the credential can
+ // only be delivered to this process.
+ const state = randomBytes(16).toString("hex")
+ await startCallbackServer()
+
+ const webUrl = (process.env.ALTIMATE_WEB_URL || DEFAULT_WEB_URL).replace(/\/+$/, "")
+ const redirect = `http://localhost:${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 waitForCallback(state)
+ // 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: creds.api_key,
+ })
+ return { type: "success", key: creds.api_key, provider: "altimate-backend" }
+ } catch {
+ return { type: "failed" }
+ } finally {
+ 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 01ed44a77..1cb172845 100644
--- a/packages/opencode/src/cli/cmd/providers.ts
+++ b/packages/opencode/src/cli/cmd/providers.ts
@@ -319,6 +319,9 @@ export const ProvidersLoginCommand = cmd({
})
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/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 8eceb72c0..f92673056 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -3,18 +3,17 @@ import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
-import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
+import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { Installation } from "@/installation"
import { UPGRADE_KV_KEY } from "./component/upgrade-indicator-utils"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
-import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { SyncProvider, useSync } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
-import { DialogModel, useConnected } from "@tui/component/dialog-model"
+import { DialogModel, DialogModelWelcome, useConnected, useReady } from "@tui/component/dialog-model"
import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
@@ -470,18 +469,12 @@ function App() {
})
})
- 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 routes/home.tsx),
+ // not an auto-opened modal. The welcome panel's readiness-aware tips guide the user to
+ // run /connect, which opens the curated DialogModelWelcome picker.
const connected = useConnected()
+ const ready = useReady()
command.register(() => [
{
title: "Switch session",
@@ -659,14 +652,14 @@ function App() {
},
},
{
- title: "Connect provider",
+ title: "Connect to your AI model provider",
value: "provider.connect",
suggested: !connected(),
slash: {
name: "connect",
},
onSelect: () => {
- dialog.replace(() => )
+ dialog.replace(() => )
},
category: "Provider",
},
@@ -891,6 +884,9 @@ function App() {
// altimate_change start — branding: altimate upgrade
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
kv.set(UPGRADE_KV_KEY, evt.properties.version)
+ // altimate_change — don't cover the first-run welcome panel with the update toast;
+ // the upgrade indicator in the footer still surfaces it. Show once a model is ready.
+ if (!ready()) return
toast.show({
variant: "info",
title: "Update Available",
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
index c30b8d12a..3a678ce8e 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
@@ -1,11 +1,14 @@
-import { createMemo, createSignal } from "solid-js"
+import { createMemo, createSignal, For, Show, onMount } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
-import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
+import { map, pipe, flatMap, entries, filter, sortBy } from "remeda"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
-import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
+import { createDialogProviderOptions, DialogProvider, WARNLIST } from "./dialog-provider"
import { useKeybind } from "../context/keybind"
+import { useTheme, selectedForeground } from "@tui/context/theme"
+import { TextAttributes, RGBA } from "@opentui/core"
+import { useKeyboard } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
export function useConnected() {
@@ -15,6 +18,21 @@ export function useConnected() {
)
}
+// altimate_change start — 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 PROTO_FRESH relaunch is a clean fresh-user state).
+const [setupComplete, setSetupComplete] = createSignal(false)
+export function markSetupComplete() {
+ setSetupComplete(true)
+}
+export function useReady() {
+ const connected = useConnected()
+ return createMemo(() => connected() || setupComplete())
+}
+// altimate_change end
+
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const sync = useSync()
@@ -25,108 +43,93 @@ 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 !== 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((x) => x.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: () => {
- dialog.clear()
- local.model.set({ providerID: provider.id, modelID: model.id }, { recent: true })
- },
- },
- ]
- })
- }
+ 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)),
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,
- 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() {
- dialog.clear()
- local.model.set({ providerID: provider.id, modelID: model }, { recent: true })
- },
- })),
- filter((x) => {
- if (!showSections) return true
- if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
- return false
- if (recents.some((item) => item.providerID === x.value.providerID && item.modelID === x.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() {
+ dialog.clear()
+ local.model.set({ providerID: provider.id, modelID }, { recent: true })
+ markSetupComplete()
+ },
+ }
}),
- sortBy(
- (x) => x.footer !== "Free",
- (x) => x.title,
- ),
+ 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(() =>
@@ -163,3 +166,240 @@ export function DialogModel(props: { providerID?: string }) {
/>
)
}
+
+// altimate_change start — 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
+}
+
+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",
+ activate: () => connectProvider("altimate-backend"),
+ },
+ { name: "Anthropic (Claude)", note: "bring your own API key", tone: "muted", activate: () => connectProvider("anthropic") },
+ { name: "OpenAI (GPT)", note: "bring your own API key", tone: "muted", activate: () => connectProvider("openai") },
+ { name: "Google (Gemini)", note: "bring your own API key", tone: "muted", activate: () => connectProvider("google") },
+ {
+ name: "Big Pickle",
+ note: "free, no signup — slower, unreliable tool-calling",
+ tone: "warning",
+ activate: chooseBigPickle,
+ },
+ { name: "Search all providers…", note: "/", tone: "muted", activate: openFullCatalog },
+ ])
+
+ // 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}
+
+
+
+ {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}
+
+
+ )}
+
+
+
+ )
+}
+// altimate_change end
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
index e28d4058d..b9fe910ae 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
@@ -1,5 +1,6 @@
import { createMemo, createSignal, onMount, Show } from "solid-js"
import { useSync } from "@tui/context/sync"
+import { useLocal } from "@tui/context/local"
import { map, pipe, sortBy } from "remeda"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
@@ -9,7 +10,7 @@ import { Link } from "../ui/link"
import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2"
-import { DialogModel } from "./dialog-model"
+import { DialogModel, markSetupComplete } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
@@ -18,14 +19,28 @@ import { AltimateApi } from "../../../../altimate/api/client"
// altimate_change end
const PROVIDER_PRIORITY: Record = {
- opencode: 0,
- "opencode-go": 1,
+ // 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
+
export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
@@ -35,14 +50,18 @@ export function createDialogProviderOptions() {
sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => ({
- 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,
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" : "Other",
async onSelect() {
const methods = sync.data.provider_auth[provider.id] ?? [
@@ -113,9 +132,14 @@ function AutoMethod(props: AutoMethodProps) {
const sdk = useSDK()
const dialog = useDialog()
const sync = useSync()
+ const local = useLocal()
const toast = useToast()
+ // 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)
useKeyboard((evt) => {
+ if (connected()) return
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
Clipboard.copy(code)
@@ -135,6 +159,23 @@ function AutoMethod(props: AutoMethodProps) {
}
await sdk.client.instance.dispose()
await sync.bootstrap()
+ // 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) local.model.set({ providerID: props.providerID, modelID: model }, { recent: true })
+ setConnected(true)
+ setTimeout(() => dialog.clear(), 5000)
+ return
+ }
+ // altimate_change end
+ toast.show({ message: `Connected to ${props.title}`, variant: "success" })
dialog.replace(() => )
})
@@ -144,18 +185,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…
+
)
}
@@ -232,8 +290,8 @@ function ApiMethod(props: ApiMethodProps) {
opencode: (
- Altimate Code Zen gives you access to all the best coding models at the cheapest prices with a single API
- key.
+ Altimate Code Zen gives you access to all the best coding models at the cheapest prices with a single
+ API key.
Go to https://altimate.ai/zen to get a key
@@ -243,8 +301,8 @@ function ApiMethod(props: ApiMethodProps) {
"opencode-go": (
- Altimate Code Go is a $10 per month subscription that provides reliable access to popular open coding models
- with generous usage limits.
+ Altimate Code Go is a $10 per month subscription that provides reliable access to popular open coding
+ models with generous usage limits.
Go to https://altimate.ai/zen and enable Altimate Code Go
@@ -255,18 +313,10 @@ function ApiMethod(props: ApiMethodProps) {
"altimate-backend": (
{/* altimate_change start — default-URL credential format (2-part preferred) */}
-
- Enter your Altimate credentials in this format:
-
-
- instance-name::api-key
-
-
- e.g. mycompany::abc123 (uses https://api.myaltimate.com)
-
-
- For a custom API URL, use: api-url::instance-name::api-key
-
+ Enter your Altimate credentials in this format:
+ instance-name::api-key
+ e.g. mycompany::abc123 (uses https://api.myaltimate.com)
+ For a custom API URL, use: api-url::instance-name::api-key
{/* altimate_change end */}
{validationError()!}
@@ -282,7 +332,9 @@ function ApiMethod(props: ApiMethodProps) {
if (props.providerID === "altimate-backend") {
const parsed = AltimateApi.parseAltimateKey(value)
if (!parsed) {
- setValidationError("Invalid format — use: instance-name::api-key (or api-url::instance-name::api-key for a custom URL)")
+ setValidationError(
+ "Invalid format — use: instance-name::api-key (or api-url::instance-name::api-key for a custom URL)",
+ )
return
}
const validation = await AltimateApi.validateCredentials(parsed)
diff --git a/packages/opencode/src/cli/cmd/tui/component/welcome-panel.tsx b/packages/opencode/src/cli/cmd/tui/component/welcome-panel.tsx
new file mode 100644
index 000000000..8ddd63815
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/welcome-panel.tsx
@@ -0,0 +1,102 @@
+import { Show } from "solid-js"
+import { TextAttributes } from "@opentui/core"
+import { useTheme } from "@tui/context/theme"
+import { Logo } from "@tui/component/logo"
+import { useReady } from "@tui/component/dialog-model"
+import { Installation } from "@/installation"
+
+// 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/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
index 9a5b0bd1f..ccfa18f6c 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx
@@ -2,7 +2,6 @@ import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { useKeybind } from "@tui/context/keybind"
-import { Logo } from "../component/logo"
import { Tips } from "../component/tips"
import { Locale } from "@/util/locale"
import { useSync } from "../context/sync"
@@ -15,6 +14,10 @@ import { Installation } from "@/installation"
import { useKV } from "../context/kv"
import { useCommandDialog } from "../component/dialog-command"
import { useLocal } from "../context/local"
+// altimate_change start — first-run guidance + shared boot box
+import { useReady } from "../component/dialog-model"
+import { WelcomePanel } from "../component/welcome-panel"
+// altimate_change end
// altimate_change start — upgrade indicator import
import { UpgradeIndicator } from "../component/upgrade-indicator"
// altimate_change end
@@ -87,6 +90,10 @@ export function Home() {
let prompt: PromptRef
const args = useArgs()
const local = useLocal()
+ // altimate_change start — boot box extracted to component/welcome-panel.tsx so the
+ // tips section is readiness-aware (/connect → /discover)
+ const ready = useReady()
+ // altimate_change end
onMount(() => {
if (once) return
if (route.initialPrompt) {
@@ -117,13 +124,14 @@ export function Home() {
return (
<>
+ {/* altimate_change start — boot box always shows on home (tips section is
+ readiness-aware); chat input pushed to the bottom via a growing spacer */}
+
+
-
-
-
-
-
-
+ {/* altimate_change end */}
+ {/* altimate_change — full-width input bar, Claude Code style */}
+
{
prompt = r
@@ -133,31 +141,13 @@ export function Home() {
workspaceID={route.workspaceID}
/>
- {/* altimate_change start — first-time onboarding hint */}
-
-
-
- Get started:
- /connect
- to add your API key
- ·
- /discover
- to detect your data stack
- ·
- Ctrl+P
- for all commands
-
+ {/* altimate_change — rotating tips under the input (ready users only); the
+ panel's "Tips for getting started" covers first-run guidance */}
+
+
+
- {/* altimate_change end */}
-
-
- {/* altimate_change start — pass first-time flag for beginner tips */}
-
- {/* altimate_change end */}
-
-
-