From 71d68d566f37cf3fafdab25a2702b6012e9b330f Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 9 Jul 2026 07:56:15 +0530 Subject: [PATCH 1/2] feat: [AI-7520] add Altimate LLM Gateway OAuth loopback signup to CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add an `oauth` `method:"auto"` to the Altimate auth plugin: bind a loopback server on `localhost:7317`, open the browser to the web authorize page, verify `state`, and save the gateway credential to `~/.altimate/altimate.json` - Surface `altimate-backend` first in provider selection (TUI + clack) with the "Recommended · best tool-calling · 10M free tokens" hint Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/altimate/plugin/altimate.ts | 171 +++++++++++++++++- packages/opencode/src/cli/cmd/providers.ts | 3 + .../cli/cmd/tui/component/dialog-provider.tsx | 6 + 3 files changed, 179 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index 510f0e1de..a46d215f8 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,52 @@ 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` + const authorizeUrl = + `${webUrl}/register?client=altimate-code` + + `&redirect=${encodeURIComponent(redirect)}` + + `&state=${state}` + + await open(authorizeUrl).catch(() => undefined) + + return { + url: authorizeUrl, + instructions: "Sign in with Google 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/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index e28d4058d..737595cd6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -18,6 +18,9 @@ import { AltimateApi } from "../../../../altimate/api/client" // altimate_change end const PROVIDER_PRIORITY: Record = { + // altimate_change start — surface the Altimate LLM Gateway first (social signup) + "altimate-backend": -1, + // altimate_change end opencode: 0, "opencode-go": 1, openai: 2, @@ -38,6 +41,9 @@ export function createDialogProviderOptions() { title: provider.name, value: provider.id, description: { + // altimate_change start + "altimate-backend": "Recommended · best tool-calling · 10M free tokens", + // altimate_change end opencode: "(Recommended)", anthropic: "(API key)", openai: "(ChatGPT Plus/Pro or API key)", From 43fc0153e86f36ad2ef219fe63f7cd86c212b3b4 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 9 Jul 2026 16:39:51 +0530 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20[AI-7520]=20onboarding=20UX=20?= =?UTF-8?q?=E2=80=94=20welcome=20panel,=20top-5=20picker,=20inline=20auth?= =?UTF-8?q?=20success?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `WelcomePanel` boot box (readiness-aware tips + "What is Altimate Code") on the home screen; first run is a welcoming panel, not an auto-opened modal - Restructure the model picker into READY / NEEDS-SETUP; add the curated `DialogModelWelcome` (top 5 providers + "Search all providers"), Big Pickle fallback, and `useReady` / `markSetupComplete` - `/connect` opens the curated picker - Altimate LLM Gateway sign-in confirms inline ("Authentication successful") and auto-closes (auto-selecting a model) instead of dropping into the model picker - Don't force Google: land on the sign-up page and let the user choose (drop `google_start`); reword the sign-in instruction Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/altimate/plugin/altimate.ts | 4 +- packages/opencode/src/cli/cmd/tui/app.tsx | 26 +- .../cli/cmd/tui/component/dialog-model.tsx | 406 ++++++++++++++---- .../cli/cmd/tui/component/dialog-provider.tsx | 120 ++++-- .../cli/cmd/tui/component/welcome-panel.tsx | 102 +++++ .../opencode/src/cli/cmd/tui/routes/home.tsx | 50 +-- 6 files changed, 542 insertions(+), 166 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/welcome-panel.tsx diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index a46d215f8..aefdd7b99 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -141,6 +141,8 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { 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)}` + @@ -150,7 +152,7 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { return { url: authorizeUrl, - instructions: "Sign in with Google in your browser to connect Altimate LLM Gateway.", + instructions: "Complete sign-in in your browser to connect Altimate LLM Gateway.", method: "auto", async callback() { try { 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 737595cd6..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,17 +19,28 @@ import { AltimateApi } from "../../../../altimate/api/client" // altimate_change end const PROVIDER_PRIORITY: Record = { - // altimate_change start — surface the Altimate LLM Gateway first (social signup) - "altimate-backend": -1, - // altimate_change end - 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() @@ -38,17 +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: { - // altimate_change start "altimate-backend": "Recommended · best tool-calling · 10M free tokens", - // altimate_change end - opencode: "(Recommended)", 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] ?? [ @@ -119,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) @@ -141,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(() => ) }) @@ -150,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… + ) } @@ -238,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 @@ -249,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 @@ -261,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()!} @@ -288,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 */} - - -