From 71d68d566f37cf3fafdab25a2702b6012e9b330f Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 9 Jul 2026 07:56:15 +0530 Subject: [PATCH 1/5] feat: [AI-7520] add Altimate LLM Gateway OAuth loopback signup to CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add an `oauth` `method:"auto"` to the Altimate auth plugin: bind a loopback server on `localhost:7317`, open the browser to the web authorize page, verify `state`, and save the gateway credential to `~/.altimate/altimate.json` - Surface `altimate-backend` first in provider selection (TUI + clack) with the "Recommended · best tool-calling · 10M free tokens" hint Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/altimate/plugin/altimate.ts | 171 +++++++++++++++++- packages/opencode/src/cli/cmd/providers.ts | 3 + .../cli/cmd/tui/component/dialog-provider.tsx | 6 + 3 files changed, 179 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index 510f0e1de..a46d215f8 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -1,4 +1,129 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { createServer } from "http" +import { randomBytes } from "crypto" +import open from "open" +import { AltimateApi } from "../api/client" + +// Loopback port the CLI listens on for the browser to deliver the gateway +// credential after Google sign-in. Must match the redirect the web authorize +// page posts back to. 7317 is otherwise unused in this codebase. +const CALLBACK_PORT = 7317 + +// Web app that hosts the signup/login (authorize) page. Overridable for +// dev/staging via ALTIMATE_WEB_URL. +const DEFAULT_WEB_URL = "https://app.myaltimate.com" +// Fallback gateway API base if the callback omits one. +const DEFAULT_API_URL = "https://api.myaltimate.com" + +const HTML_SUCCESS = `Altimate Code + +

Signed in ✓

You can return to your terminal.

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

Connection failed

${msg}

Please return to your terminal and try again.

` + +interface CallbackResult { + api_url: string + instance: string + api_key: string +} + +interface Pending { + state: string + resolve: (creds: CallbackResult) => void + reject: (err: Error) => void +} + +let server: ReturnType | undefined +let pending: Pending | undefined + +async function startCallbackServer(): Promise { + if (server) return + server = createServer((req, res) => { + const url = new URL(req.url || "/", `http://localhost:${CALLBACK_PORT}`) + if (url.pathname !== "/callback") { + res.writeHead(404) + res.end("Not found") + return + } + + const html = (status: number, body: string) => { + res.writeHead(status, { "Content-Type": "text/html" }) + res.end(body) + } + + const error = url.searchParams.get("error") + if (error) { + pending?.reject(new Error(error)) + pending = undefined + html(200, HTML_ERROR(error)) + return + } + + const state = url.searchParams.get("state") + // Bind the callback to the unguessable state the CLI generated — rejects a + // stray/malicious local request that didn't originate from our browser tab. + if (!pending || !state || state !== pending.state) { + const msg = "Invalid state — possible CSRF" + pending?.reject(new Error(msg)) + pending = undefined + html(400, HTML_ERROR(msg)) + return + } + + const apiKey = url.searchParams.get("key") + const instance = url.searchParams.get("instance") + const apiUrl = url.searchParams.get("url") || DEFAULT_API_URL + if (!apiKey || !instance) { + const msg = "Missing credential in callback" + pending.reject(new Error(msg)) + pending = undefined + html(400, HTML_ERROR(msg)) + return + } + + const current = pending + pending = undefined + current.resolve({ api_url: apiUrl, instance, api_key: apiKey }) + html(200, HTML_SUCCESS) + }) + + await new Promise((resolve, reject) => { + server!.listen(CALLBACK_PORT, () => resolve()) + server!.on("error", reject) + }) +} + +function stopCallbackServer() { + if (server) { + server.close() + server = undefined + } +} + +function waitForCallback(state: string, timeoutMs = 5 * 60 * 1000): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (pending) { + pending = undefined + reject(new Error("Timed out waiting for browser sign-in")) + } + }, timeoutMs) + pending = { + state, + resolve: (creds) => { + clearTimeout(timeout) + resolve(creds) + }, + reject: (err) => { + clearTimeout(timeout) + reject(err) + }, + } + }) +} export async function AltimateAuthPlugin(_input: PluginInput): Promise { return { @@ -6,8 +131,52 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { provider: "altimate-backend", methods: [ { + type: "oauth", + label: "Altimate LLM Gateway", + async authorize() { + // Bind the port BEFORE opening the browser so the credential can + // only be delivered to this process. + const state = randomBytes(16).toString("hex") + await startCallbackServer() + + const webUrl = (process.env.ALTIMATE_WEB_URL || DEFAULT_WEB_URL).replace(/\/+$/, "") + const redirect = `http://localhost:${CALLBACK_PORT}/callback` + const authorizeUrl = + `${webUrl}/register?client=altimate-code` + + `&redirect=${encodeURIComponent(redirect)}` + + `&state=${state}` + + await open(authorizeUrl).catch(() => undefined) + + return { + url: authorizeUrl, + instructions: "Sign in with Google in your browser to connect Altimate LLM Gateway.", + method: "auto", + async callback() { + try { + const creds = await waitForCallback(state) + // Persist to ~/.altimate/altimate.json — the provider loader + // reads this first (it carries the instance/tenant + api_url + // the generic auth.json store can't). + await AltimateApi.saveCredentials({ + altimateUrl: creds.api_url, + altimateInstanceName: creds.instance, + altimateApiKey: creds.api_key, + }) + return { type: "success", key: creds.api_key, provider: "altimate-backend" } + } catch { + return { type: "failed" } + } finally { + stopCallbackServer() + } + }, + } + }, + }, + { + // Fallback: paste an instance-name::api-key manually. type: "api", - label: "Connect to Altimate", + label: "Paste API key", }, ], }, diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 01ed44a77..1cb172845 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -319,6 +319,9 @@ export const ProvidersLoginCommand = cmd({ }) const priority: Record = { + // altimate_change start — surface the Altimate LLM Gateway first + "altimate-backend": -1, + // altimate_change end opencode: 0, openai: 1, "github-copilot": 2, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index e28d4058d..737595cd6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -18,6 +18,9 @@ import { AltimateApi } from "../../../../altimate/api/client" // altimate_change end const PROVIDER_PRIORITY: Record = { + // altimate_change start — surface the Altimate LLM Gateway first (social signup) + "altimate-backend": -1, + // altimate_change end opencode: 0, "opencode-go": 1, openai: 2, @@ -38,6 +41,9 @@ export function createDialogProviderOptions() { title: provider.name, value: provider.id, description: { + // altimate_change start + "altimate-backend": "Recommended · best tool-calling · 10M free tokens", + // altimate_change end opencode: "(Recommended)", anthropic: "(API key)", openai: "(ChatGPT Plus/Pro or API key)", From 43fc0153e86f36ad2ef219fe63f7cd86c212b3b4 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 9 Jul 2026 16:39:51 +0530 Subject: [PATCH 2/5] =?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 3/5] 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 4/5] =?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 5/5] 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