From 71d68d566f37cf3fafdab25a2702b6012e9b330f Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 9 Jul 2026 07:56:15 +0530 Subject: [PATCH 01/10] 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 02/10] =?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 */} - - - From 9c481ecf8ce06f00d7411991633942be57389ee3 Mon Sep 17 00:00:00 2001 From: Sarav Date: Mon, 13 Jul 2026 15:48:20 +0530 Subject: [PATCH 03/10] feat: [AI-7520] add /auth and /logout TUI commands - /auth: sign in to the Altimate LLM Gateway directly via the OAuth loopback, skipping the provider picker (new `DialogAltimateAuth`) - /logout: clear the stored gateway credential (`AltimateApi.clearCredentials`) and disconnect (dispose + bootstrap; the provider loader drops the now-stale auth-store entry on reload) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/altimate/api/client.ts | 6 ++++ packages/opencode/src/cli/cmd/tui/app.tsx | 33 ++++++++++++++++++ .../cli/cmd/tui/component/dialog-provider.tsx | 34 +++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index e1a0f8fef..5e01fe5f1 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,6 +135,11 @@ 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_-]*$/ /** Validates credentials against the Altimate API. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index f92673056..9dbc9d36c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -14,6 +14,9 @@ import { SDKProvider, useSDK } from "@tui/context/sdk" import { SyncProvider, useSync } from "@tui/context/sync" import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel, DialogModelWelcome, useConnected, useReady } from "@tui/component/dialog-model" +// altimate_change — /auth (gateway sign-in) + /logout commands +import { DialogAltimateAuth } from "@tui/component/dialog-provider" +import { AltimateApi } from "../../../altimate/api/client" import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" import { DialogThemeList } from "@tui/component/dialog-theme-list" @@ -663,6 +666,36 @@ function App() { }, category: "Provider", }, + // altimate_change start — /auth: sign in to the Altimate LLM Gateway directly; + // /logout: clear the stored gateway credential and disconnect. + { + title: "Sign in to Altimate LLM Gateway", + value: "altimate.auth", + suggested: !connected(), + slash: { + name: "auth", + }, + onSelect: () => { + dialog.replace(() => ) + }, + category: "Provider", + }, + { + title: "Sign out of Altimate LLM Gateway", + value: "altimate.logout", + slash: { + name: "logout", + }, + onSelect: async () => { + await AltimateApi.clearCredentials() + await sdk.client.instance.dispose() + await sync.bootstrap() + toast.show({ message: "Signed out of Altimate LLM Gateway", variant: "success" }) + dialog.clear() + }, + category: "Provider", + }, + // altimate_change end { title: "View status", keybind: "status_view", 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 b9fe910ae..fff8e1be7 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -121,6 +121,40 @@ 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() + + onMount(async () => { + const providerID = "altimate-backend" + const result = await sdk.client.provider.oauth.authorize({ providerID, method: 0 }) + if (result.data?.method === "auto") { + dialog.replace(() => ( + + )) + } else if (result.data?.method === "code") { + dialog.replace(() => ( + + )) + } else { + dialog.clear() + } + }) + + return ( + + + Altimate LLM Gateway + + Starting sign-in… + + ) +} +// altimate_change end + interface AutoMethodProps { index: number providerID: string From df61fdfc516c86875b12706baabd2be2b7d0cf62 Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 14 Jul 2026 07:32:13 +0530 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20[AI-7520]=20mark=20the=20selected?= =?UTF-8?q?=20provider=20with=20a=20green=20=E2=9C=93=20in=20the=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DialogModelWelcome now tags each row with its providerID (and modelID for Big Pickle) and compares against local.model.current(), rendering a bright-green ✓ plus a "· selected" note on the active provider — so the user can see at a glance that e.g. Altimate LLM Gateway is already selected. Bright green (diffHighlightAdded) is used because plain ANSI green renders dim in some terminals. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/cmd/tui/component/dialog-model.tsx | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) 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 3a678ce8e..8c7db8dd3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -179,6 +179,10 @@ interface WelcomeRow { 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 }) { @@ -211,20 +215,31 @@ export function DialogModelWelcome(props: { intro?: string }) { 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", 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: "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) { @@ -264,8 +279,12 @@ export function DialogModelWelcome(props: { intro?: string }) { {props.row.name} + {/* bright green so it reads clearly even where ANSI green renders dim */} + + {isCurrent(props.row) ? "✓" : " "} + - {props.row.note} + {isCurrent(props.row) ? `${props.row.note} · selected` : props.row.note} ) From d7f0f2c0c82af14678950c634e5b977301348c2b Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 14 Jul 2026 10:55:03 +0530 Subject: [PATCH 05/10] refactor: [AI-7520] isolate onboarding into an altimate-owned file (rebase safety) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shrink our footprint on the upstream opencode `dialog-model.tsx` so future upstream merges are easier to rebase: - Move `useReady`/`markSetupComplete`, `DialogModelWelcome`, and `DialogBigPickleConfirm` into a new altimate-owned `component/altimate-onboarding.tsx` (zero rebase surface — our file) - `dialog-model.tsx` (424 → 160 lines) now holds only pristine `useConnected` plus the `DialogModel` READY/NEEDS-SETUP restructure, fully wrapped in `altimate_change` markers so an upstream conflict is confined and human-resolvable - Repoint imports in `app.tsx`, `home.tsx`, `dialog-provider.tsx`, `welcome-panel.tsx` The onboarding file imports `DialogModel`/`useConnected` back from `dialog-model`, but only inside callbacks/JSX — the circular reference is runtime-only and safe. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/cli/cmd/tui/app.tsx | 3 +- .../cmd/tui/component/altimate-onboarding.tsx | 282 +++++++++++++++++ .../cli/cmd/tui/component/dialog-model.tsx | 284 +----------------- .../cli/cmd/tui/component/dialog-provider.tsx | 3 +- .../cli/cmd/tui/component/welcome-panel.tsx | 2 +- .../opencode/src/cli/cmd/tui/routes/home.tsx | 2 +- 6 files changed, 298 insertions(+), 278 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/altimate-onboarding.tsx diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 9dbc9d36c..c67defebc 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -13,7 +13,8 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog" import { SDKProvider, useSDK } from "@tui/context/sdk" import { SyncProvider, useSync } from "@tui/context/sync" import { LocalProvider, useLocal } from "@tui/context/local" -import { DialogModel, DialogModelWelcome, useConnected, useReady } from "@tui/component/dialog-model" +import { DialogModel, useConnected } from "@tui/component/dialog-model" +import { DialogModelWelcome, useReady } from "@tui/component/altimate-onboarding" // altimate_change — /auth (gateway sign-in) + /logout commands import { DialogAltimateAuth } from "@tui/component/dialog-provider" import { AltimateApi } from "../../../altimate/api/client" diff --git a/packages/opencode/src/cli/cmd/tui/component/altimate-onboarding.tsx b/packages/opencode/src/cli/cmd/tui/component/altimate-onboarding.tsx new file mode 100644 index 000000000..0d07f3518 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/altimate-onboarding.tsx @@ -0,0 +1,282 @@ +// 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 "@tui/context/local" +import { useDialog } from "@tui/ui/dialog" +import { useTheme, selectedForeground } from "@tui/context/theme" +import { TextAttributes, RGBA } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { createDialogProviderOptions } from "./dialog-provider" +import { DialogModel, useConnected } from "./dialog-model" + +// 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) +} +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/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 8c7db8dd3..9c3c13685 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, For, Show, onMount } from "solid-js" +import { createMemo, createSignal } from "solid-js" import { useLocal } from "@tui/context/local" import { useSync } from "@tui/context/sync" import { map, pipe, flatMap, entries, filter, sortBy } from "remeda" @@ -6,10 +6,12 @@ import { DialogSelect } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" 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" +// 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" export function useConnected() { const sync = useSync() @@ -18,21 +20,10 @@ 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 - +// 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() @@ -166,259 +157,4 @@ 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 - // 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} - - - )} - - - - ) -} // 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 fff8e1be7..fc959d5d3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -10,7 +10,8 @@ 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, markSetupComplete } from "./dialog-model" +import { DialogModel } from "./dialog-model" +import { markSetupComplete } from "./altimate-onboarding" import { useKeyboard } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { useToast } from "../ui/toast" diff --git a/packages/opencode/src/cli/cmd/tui/component/welcome-panel.tsx b/packages/opencode/src/cli/cmd/tui/component/welcome-panel.tsx index 8ddd63815..2f317a752 100644 --- a/packages/opencode/src/cli/cmd/tui/component/welcome-panel.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/welcome-panel.tsx @@ -2,7 +2,7 @@ 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 { useReady } from "@tui/component/altimate-onboarding" import { Installation } from "@/installation" // altimate_change — Claude-Code-style full-width boot box: big block wordmark on diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index ccfa18f6c..3d3f092f5 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -15,7 +15,7 @@ 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 { useReady } from "../component/altimate-onboarding" import { WelcomePanel } from "../component/welcome-panel" // altimate_change end // altimate_change start — upgrade indicator import From e46fdd48b44e91e402f3a5fa1a389bde43f7451d Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 16 Jul 2026 12:53:13 +0530 Subject: [PATCH 06/10] fix: [AI-7520] harden CLI gateway loopback (PR review majors) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address sahrizvi's review on #1001: - Register the pending flow in a state-keyed Map synchronously in authorize() BEFORE opening the browser, and have callback() await that promise — an already-signed-in user's instant redirect is no longer dropped as CSRF, and concurrent /auth flows no longer clobber each other - Bind the callback server to 127.0.0.1 (was all-interfaces / LAN-reachable) - Validate `state` BEFORE honoring the `?error=` branch, so an unauthenticated request can't cancel an in-progress sign-in - HTML-escape reflected values in the callback error page (localhost XSS) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/altimate/plugin/altimate.ts | 82 +++++++++++-------- 1 file changed, 46 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index aefdd7b99..0c2665e45 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -5,8 +5,8 @@ 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. +// 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 @@ -15,6 +15,15 @@ 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, + ) +} + const HTML_SUCCESS = `Altimate Code

Signed in ✓

You can return to your terminal.

@@ -22,7 +31,7 @@ const HTML_SUCCESS = `Altimate Code< const HTML_ERROR = (msg: string) => `<!doctype html><meta charset="utf-8"><title>Altimate Code -

Connection failed

${msg}

Please return to your terminal and try again.

` +

Connection failed

${escapeHtml(msg)}

Please return to your terminal and try again.

` interface CallbackResult { api_url: string @@ -31,13 +40,16 @@ interface CallbackResult { } interface Pending { - state: string resolve: (creds: CallbackResult) => void reject: (err: Error) => void } let server: ReturnType | undefined -let pending: Pending | 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 @@ -54,22 +66,20 @@ async function startCallbackServer(): Promise { res.end(body) } - const error = url.searchParams.get("error") - if (error) { - pending?.reject(new Error(error)) - pending = undefined - html(200, HTML_ERROR(error)) + // 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 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)) + const error = url.searchParams.get("error") + if (error) { + entry.reject(new Error(error)) + html(200, HTML_ERROR(error)) return } @@ -78,20 +88,19 @@ async function startCallbackServer(): Promise { 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 + entry.reject(new Error(msg)) html(400, HTML_ERROR(msg)) return } - const current = pending - pending = undefined - current.resolve({ api_url: apiUrl, instance, api_key: apiKey }) + entry.resolve({ api_url: apiUrl, instance, api_key: apiKey }) html(200, HTML_SUCCESS) }) await new Promise((resolve, reject) => { - server!.listen(CALLBACK_PORT, () => resolve()) + // 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) }) } @@ -103,16 +112,15 @@ function stopCallbackServer() { } } -function waitForCallback(state: string, timeoutMs = 5 * 60 * 1000): Promise { +// 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) { - pending = undefined - reject(new Error("Timed out waiting for browser sign-in")) - } + if (pending.delete(state)) reject(new Error("Timed out waiting for browser sign-in")) }, timeoutMs) - pending = { - state, + pending.set(state, { resolve: (creds) => { clearTimeout(timeout) resolve(creds) @@ -121,7 +129,7 @@ function waitForCallback(state: string, timeoutMs = 5 * 60 * 1000): Promise { 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() + // 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) const webUrl = (process.env.ALTIMATE_WEB_URL || DEFAULT_WEB_URL).replace(/\/+$/, "") const redirect = `http://localhost:${CALLBACK_PORT}/callback` @@ -156,7 +165,7 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { method: "auto", async callback() { try { - const creds = await waitForCallback(state) + const creds = await result // 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). @@ -169,7 +178,8 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { } catch { return { type: "failed" } } finally { - stopCallbackServer() + // Keep the shared server up while another flow is still waiting. + if (pending.size === 0) stopCallbackServer() } }, } From f5596b40a5146219cfc9871151849bcfc1d0b5f9 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 16 Jul 2026 13:01:11 +0530 Subject: [PATCH 07/10] fix: [AI-7520] address PR #1001 review minors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - altimate.ts: reset the callback server on a failed listen (EADDRINUSE no longer wedges the singleton) + surface the reason; validate the callback instance name before persisting; log the failure reason on the auth catch path - dialog-provider: DialogAltimateAuth onMount wrapped in try/catch (no more stuck "Starting sign-in…"); AutoMethod guards post-await updates + clears its auto-close timer on unmount (onCleanup); on connect-with-no-model, open the picker instead of faking a green ✓ - dialog-model: guard the favorite keybind against string (NEEDS-SETUP) rows; treat undefined model cost as not-paid in providerReady() and useConnected() - app.tsx /logout: try/catch with error toast + reset the setupComplete flag - altimate-onboarding: add resetSetupComplete() Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/altimate/api/client.ts | 8 ++- .../opencode/src/altimate/plugin/altimate.ts | 34 ++++++++--- packages/opencode/src/cli/cmd/tui/app.tsx | 18 ++++-- .../cmd/tui/component/altimate-onboarding.tsx | 57 +++++++++++++++---- .../cli/cmd/tui/component/dialog-model.tsx | 11 +++- .../cli/cmd/tui/component/dialog-provider.tsx | 54 +++++++++++++----- 6 files changed, 140 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 5e01fe5f1..545a49c89 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -142,6 +142,11 @@ export namespace AltimateApi { 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: { @@ -284,8 +289,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 0c2665e45..c9e25175e 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -97,12 +97,24 @@ async function startCallbackServer(): Promise { html(200, HTML_SUCCESS) }) - 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) - }) + 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() { @@ -166,6 +178,11 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { 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}`) + } // 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). @@ -175,7 +192,10 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { altimateApiKey: creds.api_key, }) return { type: "success", key: creds.api_key, provider: "altimate-backend" } - } catch { + } 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. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index c67defebc..3a62f4e9e 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -14,7 +14,7 @@ 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 { DialogModelWelcome, useReady } from "@tui/component/altimate-onboarding" +import { DialogModelWelcome, useReady, resetSetupComplete } from "@tui/component/altimate-onboarding" // altimate_change — /auth (gateway sign-in) + /logout commands import { DialogAltimateAuth } from "@tui/component/dialog-provider" import { AltimateApi } from "../../../altimate/api/client" @@ -688,11 +688,17 @@ function App() { name: "logout", }, onSelect: async () => { - await AltimateApi.clearCredentials() - await sdk.client.instance.dispose() - await sync.bootstrap() - toast.show({ message: "Signed out of Altimate LLM Gateway", variant: "success" }) - dialog.clear() + try { + await AltimateApi.clearCredentials() + resetSetupComplete() + await sdk.client.instance.dispose() + await sync.bootstrap() + toast.show({ message: "Signed out of Altimate LLM Gateway", variant: "success" }) + } catch (err) { + toast.error(err instanceof Error ? err : new Error("Sign-out failed")) + } finally { + dialog.clear() + } }, category: "Provider", }, diff --git a/packages/opencode/src/cli/cmd/tui/component/altimate-onboarding.tsx b/packages/opencode/src/cli/cmd/tui/component/altimate-onboarding.tsx index 0d07f3518..d95d12c77 100644 --- a/packages/opencode/src/cli/cmd/tui/component/altimate-onboarding.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/altimate-onboarding.tsx @@ -21,6 +21,11 @@ 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()) @@ -77,9 +82,27 @@ export function DialogModelWelcome(props: { intro?: string }) { 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: "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", @@ -129,12 +152,27 @@ export function DialogModelWelcome(props: { intro?: string }) { const Row = (props: { row: WelcomeRow; index: number }) => { const active = createMemo(() => selected() === props.index) return ( - setSelected(props.index)} onMouseUp={() => props.row.activate()}> + setSelected(props.index)} + onMouseUp={() => props.row.activate()} + > {active() ? "›" : " "} - - + + {props.row.name} @@ -249,12 +287,7 @@ export function DialogBigPickleConfirm(props: { origin: "welcome" | "model" }) { {(option, index) => ( - setSelected(index())} - onMouseUp={() => option.run()} - > + setSelected(index())} onMouseUp={() => option.run()}> {selected() === index() ? "›" : " "} 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 9c3c13685..361207021 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -16,7 +16,11 @@ import { markSetupComplete, DialogBigPickleConfirm } from "./altimate-onboarding export function useConnected() { const sync = useSync() return createMemo(() => - sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), + // altimate_change — treat undefined cost as "not paid" (upstream `!== 0` was + // true for undefined, mislabeling OpenCode as connected without a Zen key). + sync.data.provider.some( + (x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input != null && y.cost.input !== 0), + ), ) } @@ -40,7 +44,7 @@ export function DialogModel(props: { providerID?: string }) { 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) + 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 } @@ -145,6 +149,9 @@ export function DialogModel(props: { providerID?: string }) { title: "Favorite", disabled: !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 }) }, }, 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 fc959d5d3..891b907c2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/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 "@tui/context/sync" import { useLocal } from "@tui/context/local" import { map, pipe, sortBy } from "remeda" @@ -128,19 +128,26 @@ export function DialogAltimateAuth() { const { theme } = useTheme() const sdk = useSDK() const dialog = useDialog() + const toast = useToast() onMount(async () => { const providerID = "altimate-backend" - const result = await sdk.client.provider.oauth.authorize({ providerID, method: 0 }) - if (result.data?.method === "auto") { - dialog.replace(() => ( - - )) - } else if (result.data?.method === "code") { - dialog.replace(() => ( - - )) - } else { + try { + const result = await sdk.client.provider.oauth.authorize({ providerID, method: 0 }) + 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() } }) @@ -172,6 +179,13 @@ function AutoMethod(props: AutoMethodProps) { // 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) + }) useKeyboard((evt) => { if (connected()) return @@ -188,12 +202,14 @@ function AutoMethod(props: AutoMethodProps) { providerID: props.providerID, method: props.index, }) + if (disposed) return if (result.error) { dialog.clear() return } 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…". @@ -204,9 +220,21 @@ function AutoMethod(props: AutoMethodProps) { 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 }) + 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) - setTimeout(() => dialog.clear(), 5000) + closeTimer = setTimeout(() => { + if (!disposed) dialog.clear() + }, 5000) return } // altimate_change end From 7e7ac7c6084e738a20e5166a1684fc7b55a52c88 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 16 Jul 2026 13:46:25 +0530 Subject: [PATCH 08/10] feat: [AI-7520] exchange the login_token server-side (no api_key in loopback URL) The loopback now receives a short-lived `token` (login_token) instead of the raw key; callback() exchanges it via POST /auth/social/exchange (AltimateApi. exchangeSocialToken) for the auth_token and saves that. Single code path for both the Google and email/password connect flows. State validation, loopback bind, HTML escaping, and the pending-Map/one-time-server logic are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/altimate/api/client.ts | 29 +++++++++++++++++++ .../opencode/src/altimate/plugin/altimate.ts | 17 +++++++---- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/altimate/api/client.ts b/packages/opencode/src/altimate/api/client.ts index 545a49c89..85531e36b 100644 --- a/packages/opencode/src/altimate/api/client.ts +++ b/packages/opencode/src/altimate/api/client.ts @@ -196,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, { diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index c9e25175e..575efd856 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -36,7 +36,9 @@ const HTML_ERROR = (msg: string) => ` { return } - const apiKey = url.searchParams.get("key") + const token = url.searchParams.get("token") const instance = url.searchParams.get("instance") const apiUrl = url.searchParams.get("url") || DEFAULT_API_URL - if (!apiKey || !instance) { + 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, api_key: apiKey }) + entry.resolve({ api_url: apiUrl, instance, token }) html(200, HTML_SUCCESS) }) @@ -183,15 +185,18 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise<Hooks> { 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: creds.api_key, + altimateApiKey: authToken, }) - return { type: "success", key: creds.api_key, provider: "altimate-backend" } + 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. From 40c80361c8459e9695678617ad7f0ad56454676e Mon Sep 17 00:00:00 2001 From: Sarav <sarav.majestic@gmail.com> Date: Thu, 16 Jul 2026 19:39:10 +0530 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20[AI-7520]=20PR=20#1001=20re-review?= =?UTF-8?q?=20=E2=80=94=20loopback=20IPv4/UX=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NEW-2: callback redirect uses http://127.0.0.1 to match the loopback bind (a plain `localhost` redirect can resolve to ::1 and hit a closed port) - NEW-6: neutral browser copy ("Authorization received") — the loopback replies before the CLI has exchanged/persisted, so it must not claim "Signed in" - NEW-5: guard the /connect OAuth authorize path with try/catch → toast + clear (parity with DialogAltimateAuth; port-collision no longer fails silently) - NEW-4: AutoMethod surfaces a toast on callback failure instead of clearing silently (the precise reason is logged server-side) - NEW-3: swallow a never-awaited pending rejection (void result.catch) to avoid an unhandled rejection when the dialog is dismissed before callback() runs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../opencode/src/altimate/plugin/altimate.ts | 14 ++++- .../cli/cmd/tui/component/dialog-provider.tsx | 53 ++++++++++++++----- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index 575efd856..af4a40072 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -24,9 +24,12 @@ function escapeHtml(s: string): 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 = `<!doctype html><meta charset="utf-8"><title>Altimate Code -

Signed in ✓

You can return to your terminal.

+

Authorization received

Return to your terminal to finish connecting.

` const HTML_ERROR = (msg: string) => `Altimate Code @@ -161,9 +164,16 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { // 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(/\/+$/, "") - const redirect = `http://localhost:${CALLBACK_PORT}/callback` + // 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 = 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 891b907c2..a3b8b1659 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -46,6 +46,7 @@ export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() const sdk = useSDK() + const toast = useToast() const options = createMemo(() => { return pipe( sync.data.provider_next.all, @@ -92,19 +93,38 @@ export function createDialogProviderOptions() { if (index == null) return const method = methods[index] if (method.type === "oauth") { - const result = await sdk.client.provider.oauth.authorize({ - providerID: provider.id, - method: index, - }) - if (result.data?.method === "code") { - dialog.replace(() => ( - - )) - } - if (result.data?.method === "auto") { - dialog.replace(() => ( - - )) + // 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: provider.id, + method: index, + }) + 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() } } if (method.type === "api") { @@ -204,6 +224,13 @@ function AutoMethod(props: AutoMethodProps) { }) 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.error( + result.error instanceof Error + ? result.error + : new Error("Sign-in didn't complete. Please try again (see the terminal/logs for details)."), + ) dialog.clear() return } From e6ffd25e9574aa6b415ef5057415d62aa4118f0f Mon Sep 17 00:00:00 2001 From: Sarav Date: Fri, 17 Jul 2026 07:47:59 +0530 Subject: [PATCH 10/10] fix: [AI-7520] show Altimate LLM Gateway first in the full model list The welcome/top-5 picker already leads with the Altimate LLM Gateway. Order the full DialogModel list the same way: export `PROVIDER_PRIORITY` and sort the READY section's providers by it (the NEEDS-SETUP section already used it), so the gateway leads whether or not it is connected. Co-Authored-By: Claude Opus 4.8 --- packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx | 5 ++++- .../opencode/src/cli/cmd/tui/component/dialog-provider.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) 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 361207021..a4d825513 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -4,7 +4,7 @@ import { useSync } from "@tui/context/sync" 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, WARNLIST } from "./dialog-provider" +import { createDialogProviderOptions, DialogProvider, WARNLIST, PROVIDER_PRIORITY } from "./dialog-provider" import { useKeybind } from "../context/keybind" import * as fuzzysort from "fuzzysort" // altimate_change — onboarding helpers (readiness state, welcome picker, Big Pickle @@ -57,6 +57,9 @@ export function DialogModel(props: { providerID?: string }) { const readyOptions = pipe( sync.data.provider, 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, 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 a3b8b1659..834f669e0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -19,7 +19,7 @@ import { useToast } from "../ui/toast" import { AltimateApi } from "../../../../altimate/api/client" // altimate_change end -const PROVIDER_PRIORITY: Record = { +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