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

Authorization received

Return to your terminal to finish connecting.

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

Connection failed

${escapeHtml(msg)}

Please return to your terminal and try again.

` + +interface CallbackResult { + api_url: string + instance: string + // Short-lived, one-time login_token delivered by the browser. Exchanged for + // the gateway auth_token in callback() — the raw api_key never rides in the URL. + token: string +} + +interface Pending { + resolve: (creds: CallbackResult) => void + reject: (err: Error) => void +} + +let server: ReturnType | undefined +// Pending flows keyed by the unguessable `state`. Registered synchronously in +// authorize() BEFORE the browser opens, so an instant redirect (an already +// signed-in user) is matched instead of dropped; keying by state also lets two +// concurrent /auth flows coexist without clobbering each other. +const pending = new Map() + +async function startCallbackServer(): Promise { + if (server) return + server = createServer((req, res) => { + const url = new URL(req.url || "/", `http://localhost:${CALLBACK_PORT}`) + if (url.pathname !== "/callback") { + res.writeHead(404) + res.end("Not found") + return + } + + const html = (status: number, body: string) => { + res.writeHead(status, { "Content-Type": "text/html" }) + res.end(body) + } + + // Validate `state` FIRST — before honoring `error` — so a request without a + // known state can neither cancel an in-progress flow nor deliver anything. + const state = url.searchParams.get("state") + const entry = state ? pending.get(state) : undefined + if (!state || !entry) { + html(400, HTML_ERROR("Invalid or unknown sign-in state")) + return + } + pending.delete(state) + + const error = url.searchParams.get("error") + if (error) { + entry.reject(new Error(error)) + html(200, HTML_ERROR(error)) + return + } + + const token = url.searchParams.get("token") + const instance = url.searchParams.get("instance") + const apiUrl = url.searchParams.get("url") || DEFAULT_API_URL + if (!token || !instance) { + const msg = "Missing credential in callback" + entry.reject(new Error(msg)) + html(400, HTML_ERROR(msg)) + return + } + + entry.resolve({ api_url: apiUrl, instance, token }) + html(200, HTML_SUCCESS) + }) + + try { + await new Promise((resolve, reject) => { + // Bind to loopback only — the credential/abort endpoints must not be reachable + // from the LAN. + server!.listen(CALLBACK_PORT, "127.0.0.1", () => resolve()) + server!.on("error", reject) + }) + } catch (err) { + // Reset so a retry isn't blocked by the `if (server) return` guard, and surface + // a clear reason (e.g. the port is already taken) instead of hanging. + server = undefined + const code = (err as NodeJS.ErrnoException)?.code + throw new Error( + code === "EADDRINUSE" + ? `Port ${CALLBACK_PORT} is already in use — close whatever is using it and try again.` + : `Could not start the sign-in server: ${err instanceof Error ? err.message : String(err)}`, + ) + } +} + +function stopCallbackServer() { + if (server) { + server.close() + server = undefined + } +} + +// Register a pending flow keyed by `state` and return its promise. Called +// synchronously in authorize() before the browser opens; the server handler +// resolves/rejects it by state, so a fast redirect is never lost. +function registerPending(state: string, timeoutMs = 5 * 60 * 1000): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (pending.delete(state)) reject(new Error("Timed out waiting for browser sign-in")) + }, timeoutMs) + pending.set(state, { + resolve: (creds) => { + clearTimeout(timeout) + resolve(creds) + }, + reject: (err) => { + clearTimeout(timeout) + reject(err) + }, + }) + }) +} export async function AltimateAuthPlugin(_input: PluginInput): Promise { return { @@ -6,8 +156,74 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { provider: "altimate-backend", methods: [ { + type: "oauth", + label: "Altimate LLM Gateway", + async authorize() { + const state = randomBytes(16).toString("hex") + await startCallbackServer() + // Register the pending flow BEFORE opening the browser so an instant + // redirect can be matched by state rather than dropped as CSRF. + const result = registerPending(state) + // If callback() is never awaited (e.g. the dialog is dismissed before it + // runs), the pending promise still rejects on timeout — swallow that here + // so it can't surface as an unhandled rejection. callback() awaits the + // same promise independently. + void result.catch(() => {}) + + const webUrl = (process.env.ALTIMATE_WEB_URL || DEFAULT_WEB_URL).replace(/\/+$/, "") + // Use 127.0.0.1 to match the loopback bind — a plain `localhost` redirect + // can resolve to ::1 first and hit a closed IPv6 port. + const redirect = `http://127.0.0.1:${CALLBACK_PORT}/callback` + // Land on the sign-up page and let the user choose how to authenticate + // (Google today, more providers later) rather than forcing Google. + const authorizeUrl = + `${webUrl}/register?client=altimate-code` + + `&redirect=${encodeURIComponent(redirect)}` + + `&state=${state}` + + await open(authorizeUrl).catch(() => undefined) + + return { + url: authorizeUrl, + instructions: "Complete sign-in in your browser to connect Altimate LLM Gateway.", + method: "auto", + async callback() { + try { + const creds = await result + // The instance name comes from the browser callback; validate it + // before persisting (the manual paste path validates too). + if (!AltimateApi.isValidInstanceName(creds.instance)) { + throw new Error(`invalid instance name from callback: ${creds.instance}`) + } + // Exchange the short-lived, one-time login_token for the gateway + // auth_token server-side — the raw api_key never rides in the URL. + const authToken = await AltimateApi.exchangeSocialToken(creds.api_url, creds.instance, creds.token) + // Persist to ~/.altimate/altimate.json — the provider loader + // reads this first (it carries the instance/tenant + api_url + // the generic auth.json store can't). + await AltimateApi.saveCredentials({ + altimateUrl: creds.api_url, + altimateInstanceName: creds.instance, + altimateApiKey: authToken, + }) + return { type: "success", key: authToken, provider: "altimate-backend" } + } catch (err) { + // Log the reason (CSRF / timeout / invalid instance / …). Runs in the + // server process, so this goes to the log, not the TUI display. + console.error("[altimate] gateway sign-in failed:", err instanceof Error ? err.message : err) + return { type: "failed" } + } finally { + // Keep the shared server up while another flow is still waiting. + if (pending.size === 0) stopCallbackServer() + } + }, + } + }, + }, + { + // Fallback: paste an instance-name::api-key manually. type: "api", - label: "Connect to Altimate", + label: "Paste API key", }, ], }, diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 8f8dfedb2..87c30f6d5 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -371,6 +371,9 @@ export const ProvidersLoginCommand = effectCmd({ const hooks = yield* pluginSvc.list() const priority: Record = { + // altimate_change start — surface the Altimate LLM Gateway first + "altimate-backend": -1, + // altimate_change end opencode: 0, openai: 1, "github-copilot": 2, diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 75bc2de9b..ec435d535 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -21,6 +21,10 @@ import PROMPT_CONFIGURE_CODEX from "./template/configure-codex.txt" import PROMPT_DISCOVER_MCPS from "./template/discover-and-add-mcps.txt" import PROMPT_FEEDBACK from "./template/feedback.txt" // altimate_change end +// altimate_change start — Part 2 onboarding: scan-gate orchestration (invokes the +// existing /discover flow on the found path; discover.txt is unchanged) +import PROMPT_ONBOARD_CONNECT from "./template/onboard-connect.txt" +// altimate_change end type State = { commands: Record @@ -77,6 +81,7 @@ export const Default = { CONFIGURE_CODEX: "configure-codex", DISCOVER_MCPS: "discover-and-add-mcps", MCPS: "mcps", + ONBOARD_CONNECT: "onboard-connect", // altimate_change end } as const @@ -121,6 +126,19 @@ export const layer = Layer.effect( hints: hints(PROMPT_DISCOVER), } // altimate_change end + // altimate_change start — Part 2 scan gate (invoked programmatically by the + // TUI gate with "scan"/"skip"; hidden from the slash menu in autocomplete) + commands[Default.ONBOARD_CONNECT] = { + name: Default.ONBOARD_CONNECT, + description: "onboarding: scan environment and connect (scan|skip)", + source: "command", + subtask: false, + get template() { + return PROMPT_ONBOARD_CONNECT + }, + hints: hints(PROMPT_ONBOARD_CONNECT), + } + // altimate_change end commands[Default.REVIEW] = { name: Default.REVIEW, description: "review changes [commit|branch|pr], defaults to uncommitted", diff --git a/packages/opencode/src/command/template/onboard-connect.txt b/packages/opencode/src/command/template/onboard-connect.txt new file mode 100644 index 000000000..bf3f49558 --- /dev/null +++ b/packages/opencode/src/command/template/onboard-connect.txt @@ -0,0 +1,66 @@ +You are guiding a data engineer immediately after they finished setting up an AI +model (Part 1 of onboarding). Chat is now live. The user has just answered a +Yes/No "Scan your environment?" gate; the argument below is their answer. Act on +it. Never end any branch on a bare report of absence — every outcome converts +into a connection, a question, or a concrete next action. + +Argument: $ARGUMENTS + +── If the argument is "skip" (the user declined the scan) ── +Do NOT scan. Respond with exactly: + + No problem. What are you working on — a dbt project, a specific warehouse, or + just exploring? I'll help set it up when you're ready. + +Then act on their answer: +- A named warehouse (Snowflake, BigQuery, Databricks, Postgres, etc.) → walk them + through connecting it with the `warehouse_add` tool. +- A dbt project → help them point at it (offer to run /discover once they are in + the project directory). +- Just exploring → offer to explain a concept, review SQL they paste, or scaffold + a starter project. + +── If the argument is "scan" ── +Call the `project_scan` tool exactly once (read-only local inspection; nothing +leaves the machine). From its result, compute: +- hasDbt = a dbt project was found +- hasWarehouse = at least one warehouse connection was found from ANY source + (already-configured, dbt profile, Docker container, or environment variable) +- isRepo = the current directory is a git repository + +Then take the FIRST matching branch: + +1. hasWarehouse is true (a warehouse exists, with or without dbt) — FOUND: + The environment is workable. Continue exactly as the /discover command does + from here — you ALREADY have the scan results, so do not scan again. Present + what was found in a friendly summary; for each NEW connection discovered, offer + to add it with `warehouse_add` and verify with `warehouse_test`; offer + `schema_index` for any connected-but-unindexed warehouse; then show concrete + next steps. (This is the repo's standard discovery flow.) + +2. hasDbt is true AND hasWarehouse is false — dbt project, no warehouse: + A very common state (the project is here; the warehouse credentials live + elsewhere). Do not treat it as "nothing found." Say: + + Found your dbt project, but no warehouse connection yet. Which warehouse does + it run against — Snowflake, BigQuery, Databricks, something else? I'll walk + you through connecting it. + + When they name one, call `warehouse_add` for that type and guide them through it. + +3. hasDbt is false AND hasWarehouse is false AND isRepo is true — not in a dbt project: + Most likely they launched one directory up. Say: + + I didn't find a dbt project in this folder. /discover looks up from where you + launched — if your project is elsewhere, cd into it and run /discover again, + or tell me the path. + +4. hasDbt is false AND hasWarehouse is false AND isRepo is false — genuinely nothing yet: + Say: + + Nothing to connect here yet. When you've got a dbt project or warehouse handy, + run /discover and I'll pick it up. Meanwhile I can explain a concept, review + SQL you paste, or scaffold a project — want to try one? + +Keep the tone calm and honest: the scan only reads local files the user already +has; the real credential ask comes later, only when connecting a specific warehouse. diff --git a/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx b/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx index a4bbeb14a..9dc474194 100644 --- a/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx +++ b/packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx @@ -81,6 +81,20 @@ function show(api: TuiPluginApi) { api.ui.dialog.replace(() => ) } +// altimate_change start — /logout: clear the stored gateway credential. Self-contained +// (dispatched from the packages/tui slash command in app.tsx) since AltimateApi is +// opencode-side and unreachable from packages/tui. +async function logout(api: TuiPluginApi) { + try { + await AltimateApi.clearCredentials() + await api.client.instance.dispose() + api.ui.toast({ variant: "success", message: "Signed out of Altimate LLM Gateway" }) + } catch (err) { + api.ui.toast({ variant: "error", message: err instanceof Error ? err.message : "Sign-out failed" }) + } +} +// altimate_change end + const tui: TuiPlugin = async (api) => { api.keymap.registerLayer({ commands: [ @@ -93,8 +107,22 @@ const tui: TuiPlugin = async (api) => { show(api) }, }, + // altimate_change start — /logout entry point, dispatched by packages/tui/src/app.tsx + { + name: "altimate.provider.logout", + title: "Sign out of Altimate LLM Gateway", + category: "Altimate", + namespace: "palette", + run() { + void logout(api) + }, + }, + // altimate_change end ], - bindings: api.tuiConfig.keybinds.gather("altimate.palette", ["altimate.provider.connect"]), + bindings: api.tuiConfig.keybinds.gather("altimate.palette", [ + "altimate.provider.connect", + "altimate.provider.logout", + ]), }) } diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index abb2a2d64..efba932af 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -28,7 +28,13 @@ import { } from "solid-js" import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" -import { DialogProvider as DialogProviderList } from "./component/dialog-provider" +// altimate_change start — /auth (gateway sign-in) + /connect (curated welcome picker) +// + /logout commands +import { DialogAltimateAuth } from "./component/dialog-provider" +import { DialogModelWelcome, useReady, resetSetupComplete } from "./component/altimate-onboarding" +// altimate_change end +// altimate_change — Part 2 scan gate (fires once when Part 1 first completes) +import { DialogScanGate } from "./component/dialog-scan-gate" import { ErrorComponent } from "./component/error-component" import { PluginRouteMissing } from "./component/plugin-route-missing" import { ProjectProvider, useProject } from "./context/project" @@ -535,18 +541,43 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }) }) - createEffect( - on( - () => sync.status === "complete" && sync.data.provider.length === 0, - (isEmpty, wasEmpty) => { - // only trigger when we transition into an empty-provider state - if (!isEmpty || wasEmpty) return - dialog.replace(() => ) - }, - ), - ) + // altimate_change — first run is now a welcoming home-screen panel (see + // component/welcome-panel.tsx, wired in routes/home.tsx), not an auto-opened modal. + // The panel's readiness-aware tips guide the user to run /connect, which opens the + // curated DialogModelWelcome picker. const connected = useConnected() + // altimate_change — onboarding readiness (real credentials OR a completed first-run + // setup pick, e.g. Big Pickle) gates first-run chat/tips; see + // component/altimate-onboarding.tsx. Named distinctly from the plugin-host `ready` + // signal above (line ~408), which tracks TUI plugin startup, not onboarding state. + const onboardingReady = useReady() + // altimate_change start — Part 2 scan gate: fire EXACTLY once, on the first time + // this session reaches "ready" from a not-ready start (i.e. the user just finished + // Part 1 in a fresh onboarding). `prev === false` guarantees a genuine false→true + // transition, so a returning user (ready from launch, prev === undefined) never + // sees it, and a later /model change (ready stays true, no transition) never + // re-triggers it. We do NOT auto-scan — the gate just asks. + let scanGateShown = false + createEffect( + on(onboardingReady, (isReady, prev) => { + if (scanGateShown) return + if (isReady && prev === false) { + scanGateShown = true + dialog.replace(() => ( + { + const ref = promptRef.current + if (!ref) return + ref.set({ input: `/onboard-connect ${arg}`, parts: [] }) + ref.submit() + }} + /> + )) + } + }), + ) + // altimate_change end const currentWorktreeWorkspace = createMemo(() => { const workspaceID = project.workspace.current() if (!workspaceID) return @@ -735,14 +766,43 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, { name: "provider.connect", - title: "Connect provider", + title: "Connect to your AI model provider", suggested: !connected(), slashName: "connect", run: () => { - dialog.replace(() => ) + // altimate_change — curated welcome picker (Gateway + top BYOK providers + + // Big Pickle) instead of the full provider list; "Search all providers…" + // still hands off to the full DialogModel catalog. + dialog.replace(() => ) + }, + category: "Provider", + }, + // altimate_change start — /auth: sign in to the Altimate LLM Gateway directly; + // /logout: clear the stored gateway credential and disconnect. + { + name: "altimate.auth", + title: "Sign in to Altimate LLM Gateway", + suggested: !connected(), + slashName: "auth", + run: () => { + dialog.replace(() => ) + }, + category: "Provider", + }, + { + name: "altimate.logout", + title: "Sign out of Altimate LLM Gateway", + slashName: "logout", + run: () => { + // altimate_change — the credential clear + instance dispose + toast happen + // opencode-side (AltimateApi is unreachable from packages/tui); see + // packages/opencode/src/plugin/tui/altimate/provider-credentials.tsx. + keymap.dispatchCommand("altimate.provider.logout") + resetSetupComplete() }, category: "Provider", }, + // altimate_change end ...(sync.data.console_state.switchableOrgCount > 1 ? [ { @@ -1015,6 +1075,9 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // altimate_change start — upstream_fix: persist update availability for footer indicator kv.set(UPGRADE_KV_KEY, version) // altimate_change end + // altimate_change — don't cover the first-run welcome panel with the update confirm + // dialog; the footer upgrade indicator still surfaces it. Prompt once a model is ready. + if (!onboardingReady()) return const skipped = kv.get("skipped_version") if (skipped && !isVersionGreater(version, skipped)) return diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx new file mode 100644 index 000000000..5a29e8378 --- /dev/null +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -0,0 +1,316 @@ +// Altimate onboarding layer — kept in a dedicated, altimate-owned file so it does +// NOT enlarge the rebase surface of the upstream `dialog-model.tsx`. Holds the +// first-run readiness state, the curated welcome/provider picker, and the Big +// Pickle interstitial. Imports back into dialog-model are runtime-only (used inside +// callbacks/JSX), so the circular reference is safe. +import { createMemo, createSignal, For, Show, onMount } from "solid-js" +import { useLocal } from "../context/local" +import { useDialog } from "../ui/dialog" +import { useTheme, selectedForeground } from "../context/theme" +import { TextAttributes, RGBA } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { createDialogProviderOptions } from "./dialog-provider" +import { DialogModel } from "./dialog-model" +import { useConnected } from "./use-connected" + +// Session-scoped "setup complete" flag. Set when the user picks a ready model, +// chooses the free Big Pickle option, or finishes the gateway flow. Combined with +// useConnected() (real credentials) via useReady(), it gates the first-run chat +// lock. Module-global so it is shared across the app and resets on every process +// launch (so a fresh relaunch is a clean fresh-user state). +const [setupComplete, setSetupComplete] = createSignal(false) +export function markSetupComplete() { + setSetupComplete(true) +} +// Cleared on /logout so first-run tips don't keep showing "you're all set" after +// the credential is gone. +export function resetSetupComplete() { + setSetupComplete(false) +} +export function useReady() { + const connected = useConnected() + return createMemo(() => connected() || setupComplete()) +} + +// First-run welcome picker (presentation only; reuses the same action handlers as +// DialogModel/createDialogProviderOptions). A curated six: five recommended +// providers + a "Search all providers…" row that hands off to the full DialogModel +// picker. The long tail stays behind search. +const NAME_W = 24 +type WelcomeTone = "success" | "warning" | "muted" + +interface WelcomeRow { + name: string + note: string + tone: WelcomeTone + activate: () => void + // Identifies the row for the "currently selected" tick. providerID alone matches + // any model of that provider; add modelID to match a specific model (Big Pickle). + providerID?: string + modelID?: string +} + +export function DialogModelWelcome(props: { intro?: string }) { + const { theme } = useTheme() + const dialog = useDialog() + const local = useLocal() + const providers = createDialogProviderOptions() + const [selected, setSelected] = createSignal(0) + + onMount(() => dialog.setSize("large")) + + function connectProvider(id: string) { + // Reuse the exact provider onSelect (gateway flow for altimate-backend, + // auth-method screens for the BYOK providers). + providers() + .find((o) => o.value === id) + ?.onSelect?.() + } + + function chooseBigPickle() { + dialog.replace(() => ) + } + + function openFullCatalog() { + dialog.replace(() => ) + } + + const rows = createMemo(() => [ + { + name: "Altimate LLM Gateway", + note: "Recommended · best tool-calling · 10M free tokens", + tone: "success", + providerID: "altimate-backend", + activate: () => connectProvider("altimate-backend"), + }, + { + name: "Anthropic (Claude)", + note: "bring your own API key", + tone: "muted", + providerID: "anthropic", + activate: () => connectProvider("anthropic"), + }, + { + name: "OpenAI (GPT)", + note: "bring your own API key", + tone: "muted", + providerID: "openai", + activate: () => connectProvider("openai"), + }, + { + name: "Google (Gemini)", + note: "bring your own API key", + tone: "muted", + providerID: "google", + activate: () => connectProvider("google"), + }, + { + name: "Big Pickle", + note: "free, no signup — slower, unreliable tool-calling", + tone: "warning", + providerID: "opencode", + modelID: "big-pickle", + activate: chooseBigPickle, + }, + { name: "Search all providers…", note: "/", tone: "muted", activate: openFullCatalog }, + ]) + + // The currently active model → drives the green "selected" tick. + const current = createMemo(() => local.model.current()) + const isCurrent = (row: WelcomeRow) => { + const c = current() + if (!row.providerID || !c || c.providerID !== row.providerID) return false + return row.modelID ? c.modelID === row.modelID : true + } + + // Indices 0-4 are providers, 5 is the search row (rendered below a divider). + const COUNT = 6 + function move(direction: number) { + setSelected((prev) => (prev + direction + COUNT) % COUNT) + } + + useKeyboard((evt) => { + if (evt.name === "up" || (evt.ctrl && evt.name === "p")) return move(-1) + if (evt.name === "down" || (evt.ctrl && evt.name === "n")) return move(1) + if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() + rows()[selected()].activate() + return + } + // "/", ctrl+a, or any letter/number reveals the full searchable catalog. + if (evt.name === "/" || (evt.ctrl && evt.name === "a") || /^[a-z0-9]$/i.test(evt.name ?? "")) { + evt.preventDefault() + openFullCatalog() + } + }) + + const selFg = selectedForeground(theme) + const transparent = RGBA.fromInts(0, 0, 0, 0) + const noteColor = (tone: WelcomeTone) => + tone === "success" ? theme.success : tone === "warning" ? theme.warning : theme.textMuted + + const Row = (props: { row: WelcomeRow; index: number }) => { + const active = createMemo(() => selected() === props.index) + return ( + setSelected(props.index)} + onMouseUp={() => props.row.activate()} + > + + {active() ? "›" : " "} + + + + {props.row.name} + + + {/* bright green so it reads clearly even where ANSI green renders dim */} + + {isCurrent(props.row) ? "✓" : " "} + + + {isCurrent(props.row) ? `${props.row.note} · selected` : props.row.note} + + + ) + } + + return ( + + + + {props.intro} + + + + + + Select a provider + + — you can change this anytime with /model + + + {(row, i) => } + + + + + + ) +} + +// Big Pickle interstitial — one confirm, default No. Custom component (not +// DialogSelect) so the full warning wraps instead of clipping; y/n keys work, +// enter accepts the highlighted row (No by default). +export function DialogBigPickleConfirm(props: { origin: "welcome" | "model" }) { + const { theme } = useTheme() + const dialog = useDialog() + const local = useLocal() + const [selected, setSelected] = createSignal(0) // 0 = No (default) + + function no() { + dialog.replace(() => (props.origin === "welcome" ? : )) + } + function yes() { + dialog.clear() + local.model.set({ providerID: "opencode", modelID: "big-pickle" }, { recent: true }) + markSetupComplete() + } + const options = [ + { label: "No — pick something else", hint: "(default)", run: no }, + { label: "Yes — continue with Big Pickle", hint: "", run: yes }, + ] + + useKeyboard((evt) => { + if (evt.name === "up" || evt.name === "down") { + setSelected((prev) => (prev + 1) % 2) + evt.preventDefault() + return + } + if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() + options[selected()].run() + return + } + if (evt.name === "y" && !evt.ctrl && !evt.meta) { + evt.preventDefault() + yes() + return + } + if (evt.name === "n" && !evt.ctrl && !evt.meta) { + evt.preventDefault() + no() + } + }) + + const selFg = selectedForeground(theme) + const transparent = RGBA.fromInts(0, 0, 0, 0) + + return ( + + + + Use Big Pickle? + + dialog.clear()}> + esc + + + + Big Pickle works for chat but often fails at data tasks. The Gateway is free to start (10M tokens). Continue? + [y/N] + + + + {(option, index) => ( + setSelected(index())} onMouseUp={() => option.run()}> + + {selected() === index() ? "›" : " "} + + + + {option.label} + + + + {option.hint} + + + )} + + + + ) +} diff --git a/packages/tui/src/component/command-palette.tsx b/packages/tui/src/component/command-palette.tsx index 3dd6829c5..67df2d07a 100644 --- a/packages/tui/src/component/command-palette.tsx +++ b/packages/tui/src/component/command-palette.tsx @@ -9,6 +9,8 @@ import { useOpencodeKeymap, } from "../keymap" import { useTuiConfig } from "../config" +// altimate_change — first-run command filtering +import { useReady } from "./altimate-onboarding" type PaletteCommandEntry = ReturnType[number] @@ -45,8 +47,13 @@ export function CommandPaletteDialog() { bindings: registeredBindings.get(entry.command.name) ?? entry.bindings, })) }) - const options = createMemo(() => - entries().map((entry) => ({ + // altimate_change start — first run: until a model is ready, the command palette + // surfaces only commands that work without a provider (connect first, then a few + // utilities). Full palette returns once a provider is set up. + const FIRST_RUN_COMMANDS = ["provider.connect", "help.show", "theme.switch", "opencode.status", "app.exit"] + const ready = useReady() + const options = createMemo(() => { + const all = entries().map((entry) => ({ title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name, description: typeof entry.command.desc === "string" ? entry.command.desc : undefined, category: typeof entry.command.category === "string" ? entry.command.category : undefined, @@ -57,8 +64,15 @@ export function CommandPaletteDialog() { dialog.clear() keymap.dispatchCommand(entry.command.name) }, - })), - ) + })) + if (!ready()) + return FIRST_RUN_COMMANDS.flatMap((name) => { + const match = all.find((option) => option.value === name) + return match ? [match] : [] + }) + return all + }) + // altimate_change end let ref: DialogSelectRef const list = () => { diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index ff8715ef8..9b5027ff4 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -1,14 +1,23 @@ import { createMemo, createSignal } from "solid-js" import { useLocal } from "../context/local" -import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda" +import { useSync } from "../context/sync" +import { map, pipe, flatMap, entries, filter, sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" -import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" +import { createDialogProviderOptions, DialogProvider, WARNLIST, PROVIDER_PRIORITY } from "./dialog-provider" import { DialogVariant } from "./dialog-variant" import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" -import { useSync } from "../context/sync" +// altimate_change — onboarding helpers (readiness state, welcome picker, Big Pickle +// interstitial) live in the altimate-owned ./altimate-onboarding to keep this +// upstream file's rebase surface small. markSetupComplete / DialogBigPickleConfirm +// are used by the restructured DialogModel below. +import { markSetupComplete, DialogBigPickleConfirm } from "./altimate-onboarding" +// altimate_change start — DialogModel restructured from the upstream flat +// favorites/recent/provider list into READY / NEEDS-SETUP sections with a Big Pickle +// fallback. This is an in-place rewrite of the upstream component; on an upstream +// merge, expect a conflict here and re-apply the READY/NEEDS-SETUP shaping. export function DialogModel(props: { providerID?: string }) { const local = useLocal() const sync = useSync() @@ -18,112 +27,98 @@ export function DialogModel(props: { providerID?: string }) { const connected = useConnected() const providers = createDialogProviderOptions() - const showExtra = createMemo(() => connected() && !props.providerID) + // A provider is "ready" (usable now) when it has valid credentials: it is present + // in the live provider list with at least one model — and, for the free OpenCode + // provider, with at least one paid model (a Zen key entered). + function providerReady(id: string) { + const p = sync.data.provider.find((x) => x.id === id) + if (!p) return false + if (id === "opencode") return Object.values(p.models).some((m) => m.cost?.input != null && m.cost.input !== 0) + return Object.keys(p.models).length > 0 + } const options = createMemo(() => { const needle = query().trim() - const showSections = showExtra() && needle.length === 0 - const favorites = connected() ? local.model.favorite() : [] - const recents = local.model.recent() - - function toOptions(items: typeof favorites, category: string) { - if (!showSections) return [] - return items.flatMap((item) => { - const provider = sync.data.provider.find((provider) => provider.id === item.providerID) - if (!provider) return [] - const model = provider.models[item.modelID] - if (!model) return [] - return [ - { - key: item, - value: { providerID: provider.id, modelID: model.id }, - title: model.name ?? item.modelID, - description: provider.name, - category, - disabled: provider.id === "opencode" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect: () => { - onSelect(provider.id, model.id) - }, - }, - ] - }) - } + const favorites = local.model.favorite() - const favoriteOptions = toOptions(favorites, "Favorites") - const recentOptions = toOptions( - recents.filter( - (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), - ), - "Recent", - ) - - const providerOptions = pipe( + // READY — models from providers that already have valid credentials. Selecting + // one switches instantly. + const readyOptions = pipe( sync.data.provider, - sortBy( - (provider) => provider.id !== "opencode", - (provider) => provider.name, - ), + filter((provider) => providerReady(provider.id)), + // altimate_change — order ready providers by the same PROVIDER_PRIORITY as the + // welcome/NEEDS-SETUP lists so the Altimate LLM Gateway leads the full list too. + sortBy((provider) => PROVIDER_PRIORITY[provider.id] ?? 99), flatMap((provider) => pipe( provider.models, entries(), filter(([_, info]) => info.status !== "deprecated"), filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), - map(([model, info]) => ({ - value: { providerID: provider.id, modelID: model }, - title: info.name ?? model, - releaseDate: info.release_date, - description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) - ? "(Favorite)" - : undefined, - category: connected() ? provider.name : undefined, - disabled: provider.id === "opencode" && model.includes("-nano"), - footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect() { - onSelect(provider.id, model) - }, - })), - filter((option) => { - if (!showSections) return true - if ( - favorites.some( - (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID, - ) - ) - return false - if ( - recents.some( - (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID, - ) - ) - return false - return true + map(([modelID, info]) => { + const warn = WARNLIST[modelID] + const isFav = favorites.some((f) => f.providerID === provider.id && f.modelID === modelID) + return { + value: { providerID: provider.id, modelID } as { providerID: string; modelID: string } | string, + title: info.name ?? modelID, + description: warn ? `${provider.name} · ${warn}` : provider.name, + category: "READY", + footer: isFav ? "★" : undefined, + onSelect() { + // altimate_change — go through the shared onSelect(providerID, modelID) + // helper so a ready pick also honors the model-variant follow-up flow, + // and mark setup complete so the first-run chat lock lifts. + onSelect(provider.id, modelID) + markSetupComplete() + }, + } }), - (options) => sortModelOptions(options, props.providerID !== undefined), + sortBy((x) => x.title), ), ), ) - const popularProviders = !connected() - ? pipe( - providers(), - map((option) => ({ - ...option, - category: "Popular providers", - })), - take(6), - ) - : [] + // NEEDS SETUP — providers without valid credentials (selecting routes into their + // auth flow first), plus the free Big Pickle option. Hidden when scoped to one + // provider (post-connect model list). + const setupOptions = props.providerID + ? [] + : (() => { + const list = providers() + .filter((o) => !providerReady(o.value)) + .map((o) => ({ + value: o.value as { providerID: string; modelID: string } | string, + title: o.title, + description: o.description, + category: "NEEDS SETUP", + footer: undefined as string | undefined, + onSelect: o.onSelect, + })) + const bigPickle = { + value: "big-pickle" as { providerID: string; modelID: string } | string, + title: "Big Pickle", + description: "free, no signup — slower, unreliable tool-calling", + category: "NEEDS SETUP", + footer: undefined as string | undefined, + async onSelect() { + dialog.replace(() => ) + }, + } + // Big Pickle sits at priority 4 — just above OpenCode Zen (priority 5). + const zenIdx = list.findIndex((o) => o.value === "opencode") + if (zenIdx === -1) list.push(bigPickle) + else list.splice(zenIdx, 0, bigPickle) + return list + })() if (needle) { return [ - ...fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj), - ...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj), + ...fuzzysort.go(needle, readyOptions, { keys: ["title", "description"] }).map((x) => x.obj), + ...fuzzysort.go(needle, setupOptions, { keys: ["title", "description"] }).map((x) => x.obj), ] } - return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders] + return [...readyOptions, ...setupOptions] }) const provider = createMemo(() => @@ -167,6 +162,9 @@ export function DialogModel(props: { providerID?: string }) { title: "Favorite", hidden: !connected(), onTrigger: (option) => { + // altimate_change — NEEDS-SETUP rows carry plain string values (provider + // ids / "big-pickle"); only real {providerID, modelID} rows are favoritable. + if (typeof option.value === "string") return local.model.toggleFavorite(option.value as { providerID: string; modelID: string }) }, }, @@ -179,7 +177,11 @@ export function DialogModel(props: { providerID?: string }) { /> ) } +// altimate_change end +// altimate_change — kept for packages/tui/test/cli/cmd/tui/model-options.test.ts; +// no longer used internally by DialogModel above (see READY/NEEDS-SETUP shaping), +// but preserved as a public export so the existing upstream test keeps passing. export function sortModelOptions( options: T[], newestFirst: boolean, diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index e9f6ca056..a19c1ab44 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, onMount, Show } from "solid-js" +import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import { useSync } from "../context/sync" import { map, pipe, sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" @@ -15,15 +15,33 @@ import { isConsoleManagedProvider } from "../util/provider-origin" import { useConnected } from "./use-connected" import { useBindings, useOpencodeKeymap } from "../keymap" import { useClipboard } from "../context/clipboard" +import { useLocal } from "../context/local" +// altimate_change — mark first-run setup complete once the gateway sign-in succeeds +// (used by AutoMethod below); flips useReady() so the first-run chat lock lifts. +import { markSetupComplete } from "./altimate-onboarding" -const PROVIDER_PRIORITY: Record = { - opencode: 0, - "opencode-go": 1, +export const PROVIDER_PRIORITY: Record = { + // altimate_change start — Part 1 onboarding: Altimate LLM Gateway is the + // recommended default first; the BYOK providers rank next; OpenCode Zen loses + // its "Recommended" tag and drops below. (Big Pickle occupies priority 4, injected + // by dialog-model between Google and Zen.) + "altimate-backend": 0, + anthropic: 1, openai: 2, - "github-copilot": 3, - anthropic: 4, - google: 5, + google: 3, + // 4 reserved for Big Pickle (see dialog-model) + opencode: 5, + "opencode-go": 6, + "github-copilot": 7, + // altimate_change end +} + +// altimate_change start — known-bad tool-callers, surfaced inline in the model picker +// (imported by dialog-model's READY/NEEDS-SETUP list). +export const WARNLIST: Record = { + "qwen-plus": "⚠ known tool-calling issues", } +// altimate_change end const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__" const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ @@ -55,15 +73,19 @@ export function providerOptions(list: { id: string; name: string }[]): ProviderO ), map((provider) => ({ type: "provider" as const, - title: provider.name, + // altimate_change start — brand the gateway entry + relabel priorities + title: provider.id === "altimate-backend" ? "Altimate LLM Gateway" : provider.name, value: provider.id, providerID: provider.id, description: { - opencode: "(Recommended)", + "altimate-backend": "Recommended · best tool-calling · 10M free tokens", anthropic: "(API key)", openai: "(ChatGPT Plus/Pro or API key)", + google: "(API key)", + opencode: "Bring your own Zen key", "opencode-go": "Low cost subscription for everyone", }[provider.id], + // altimate_change end category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers", })), ), @@ -147,12 +169,6 @@ export function createDialogProviderOptions() { gutter: connected && onboarded() ? () => : undefined, async onSelect() { if (consoleManaged) return - // altimate_change start — restore Altimate credential validation/save/model-picker flow - if (providerID === "altimate-backend") { - keymap.dispatchCommand("altimate.provider.connect") - return - } - // altimate_change end const methods = sync.data.provider_auth[providerID] ?? [ { @@ -191,31 +207,58 @@ export function createDialogProviderOptions() { inputs = value } - const result = await sdk.client.provider.oauth.authorize({ - providerID, - method: index, - inputs, - }) - if (result.error) { - toast.show({ - variant: "error", - message: JSON.stringify(result.error), + // altimate_change — guard the authorize (e.g. loopback port busy) so the + // recommended /connect path surfaces the error instead of failing silently + // (parity with DialogAltimateAuth). + try { + const result = await sdk.client.provider.oauth.authorize({ + providerID, + method: index, + inputs, }) + if (result.error) { + toast.show({ + variant: "error", + message: JSON.stringify(result.error), + }) + dialog.clear() + return + } + if (result.data?.method === "code") { + dialog.replace(() => ( + + )) + } else if (result.data?.method === "auto") { + dialog.replace(() => ( + + )) + } else { + dialog.clear() + } + } catch (err) { + toast.error(err instanceof Error ? err : new Error("Failed to start sign-in")) dialog.clear() - return - } - if (result.data?.method === "code") { - dialog.replace(() => ( - - )) - } - if (result.data?.method === "auto") { - dialog.replace(() => ( - - )) } } if (method.type === "api") { + // altimate_change start — restore Altimate credential validation/save/model-picker + // flow: the instance-name::api-key entry is opencode-side (needs AltimateApi), so + // it's re-homed as a plugin (see docs/internal/2026-06-23-tui-fork-features-as-plugins-adr.md). + if (providerID === "altimate-backend") { + keymap.dispatchCommand("altimate.provider.connect") + return + } + // altimate_change end let metadata: Record | undefined if (method.prompts?.length) { const value = await PromptsMethod({ dialog, prompts: method.prompts }) @@ -239,6 +282,52 @@ export function DialogProvider() { return } +// altimate_change start — /auth entry: go straight to the Altimate LLM Gateway +// sign-in (the OAuth loopback method, index 0), skipping the provider picker. +export function DialogAltimateAuth() { + const { theme } = useTheme() + const sdk = useSDK() + const dialog = useDialog() + const toast = useToast() + + onMount(async () => { + const providerID = "altimate-backend" + try { + const result = await sdk.client.provider.oauth.authorize({ providerID, method: 0 }) + if (result.error) { + toast.show({ variant: "error", message: JSON.stringify(result.error) }) + dialog.clear() + return + } + if (result.data?.method === "auto") { + dialog.replace(() => ( + + )) + } else if (result.data?.method === "code") { + dialog.replace(() => ( + + )) + } else { + dialog.clear() + } + } catch (err) { + // e.g. the loopback port is busy — don't hang on "Starting sign-in…". + toast.error(err instanceof Error ? err : new Error("Failed to start sign-in")) + dialog.clear() + } + }) + + return ( + + + Altimate LLM Gateway + + Starting sign-in… + + ) +} +// altimate_change end + interface AutoMethodProps { index: number providerID: string @@ -250,25 +339,38 @@ function AutoMethod(props: AutoMethodProps) { const sdk = useSDK() const dialog = useDialog() const sync = useSync() + const local = useLocal() const toast = useToast() const clipboard = useClipboard() + // altimate_change — success state: confirm inline (green) below the "waiting" line, + // then auto-close, instead of jumping into the model picker. + const [connected, setConnected] = createSignal(false) + // Guard against a late callback / auto-close firing after the dialog is dismissed. + let disposed = false + let closeTimer: ReturnType | undefined + onCleanup(() => { + disposed = true + if (closeTimer) clearTimeout(closeTimer) + }) useBindings(() => ({ - bindings: [ - { - key: "c", - desc: "Copy provider code", - group: "Dialog", - cmd: () => { - const code = - props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url - clipboard - .write?.(code) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) - }, - }, - ], + bindings: connected() + ? [] + : [ + { + key: "c", + desc: "Copy provider code", + group: "Dialog", + cmd: () => { + const code = + props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url + clipboard + .write?.(code) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + }, + }, + ], })) onMount(async () => { @@ -276,7 +378,10 @@ function AutoMethod(props: AutoMethodProps) { providerID: props.providerID, method: props.index, }) + if (disposed) return if (result.error) { + // altimate_change — surface the failure instead of clearing silently. The + // precise reason is also logged server-side by the plugin callback. toast.show({ variant: "error", message: @@ -289,6 +394,36 @@ function AutoMethod(props: AutoMethodProps) { } await sdk.client.instance.dispose() await sync.bootstrap() + if (disposed) return + // altimate_change start — mark setup complete (flips useReady → unlocks first-run chat/tips) + markSetupComplete() + // The gateway sign-in already shows the auth URL + "Waiting for authorization…". + // On success, confirm inline (green) and auto-close after a moment rather than + // opening the model picker. Auto-select a model so the user can chat right away. + if (props.providerID === "altimate-backend") { + const provider = sync.data.provider.find((p) => p.id === props.providerID) + const model = provider + ? Object.entries(provider.models).find(([, info]) => info.status !== "deprecated")?.[0] + : undefined + if (!model) { + // Connected, but nothing usable to select — don't fake a green ✓; open the + // picker so the user can choose a model (or another provider). + toast.show({ + message: "Connected, but no model is available yet — pick one to start.", + variant: "warning", + }) + dialog.replace(() => ) + return + } + local.model.set({ providerID: props.providerID, modelID: model }, { recent: true }) + setConnected(true) + closeTimer = setTimeout(() => { + if (!disposed) dialog.clear() + }, 5000) + return + } + // altimate_change end + toast.show({ message: `Connected to ${props.title}`, variant: "success" }) dialog.replace(() => ) }) @@ -298,18 +433,35 @@ function AutoMethod(props: AutoMethodProps) { {props.title} - dialog.clear()}> - esc - + + dialog.clear()}> + esc + + {props.authorization.instructions} - Waiting for authorization... - - c copy - + {/* altimate_change — swap the "waiting" line for a green success confirmation */} + + Waiting for authorization... + + c copy + + + } + > + {/* theme.success is plain ANSI green (col 2) — dim/gray in many palettes; + diffHighlightAdded is the bright green (greenBright) so it reads clearly. */} + + ✓ Authentication successful + + You are all set — returning to Altimate Code… + ) } diff --git a/packages/tui/src/component/dialog-scan-gate.tsx b/packages/tui/src/component/dialog-scan-gate.tsx new file mode 100644 index 000000000..6170209da --- /dev/null +++ b/packages/tui/src/component/dialog-scan-gate.tsx @@ -0,0 +1,126 @@ +import { createSignal, For, onMount } from "solid-js" +import { TextAttributes, RGBA } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { useTheme, selectedForeground } from "../context/theme" +import { useDialog } from "../ui/dialog" + +// altimate_change — Part 2 scan gate. Shown once, immediately after Part 1 +// completes (a model is ready and chat is live). We do NOT auto-scan; the user +// chooses. Yes/No each submit the hidden `/onboard-connect` command (scan|skip), +// which starts a session and lets the agent run the branch flow. Yes carries a +// "(Recommended)" tag (theme.success, matching the model picker's house style). +// +// `onChoose` is injected by App (which lives inside PromptRefProvider); the dialog +// overlay is mounted above that provider, so the gate cannot resolve the prompt +// ref itself — it must be handed in. +export function DialogScanGate(props: { onChoose: (arg: "scan" | "skip") => void }) { + const { theme } = useTheme() + const dialog = useDialog() + const [selected, setSelected] = createSignal(0) // 0 = Yes (default, per spec ❯) + + onMount(() => dialog.setSize("large")) + + function run(arg: "scan" | "skip") { + dialog.clear() + props.onChoose(arg) + } + + const options = [ + { + label: "Yes", + recommended: true, + run: () => run("scan"), + help: "Reads local config and env vars. Nothing leaves your computer; no credentials needed.", + }, + { + label: "No", + recommended: false, + run: () => run("skip"), + help: "Skip for now. Run /discover anytime.", + }, + ] + + 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() + run("scan") + return + } + if (evt.name === "n" && !evt.ctrl && !evt.meta) { + evt.preventDefault() + run("skip") + } + }) + + const selFg = selectedForeground(theme) + const transparent = RGBA.fromInts(0, 0, 0, 0) + + return ( + + + + + Scan your environment? + + dialog.clear()}> + esc + + + I'll look for your dbt project and warehouses. + + + {(option, index) => { + const active = () => selected() === index() + return ( + setSelected(index())} onMouseUp={() => option.run()}> + + {active() ? "❯" : " "} + + + + {option.label} + + + + + {option.recommended ? (Recommended) : null} + {option.help} + + + + ) + }} + + + + + ) +} diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 137899db5..da6c3e5b3 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -21,6 +21,9 @@ import type { PromptInfo } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" +// altimate_change — first run: slash menu shows only local actions (e.g. /connect) +// until a model is ready +import { useReady } from "../altimate-onboarding" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -89,6 +92,8 @@ export function Autocomplete(props: { const project = useProject() const slashes = useCommandSlashes() const modeStack = useOpencodeModeStack() + // altimate_change — readiness gate for first-run slash filtering + const ready = useReady() const { theme } = useTheme() const dimensions = useTerminalDimensions() const frecency = useFrecency() @@ -448,23 +453,30 @@ export function Autocomplete(props: { ) // altimate_change end - for (const serverCommand of sync.data.command) { - if (serverCommand.source === "skill") continue - // altimate_change start — keep one autocomplete row per slash command name - if (localSlashNames.has(serverCommand.name)) continue - // altimate_change end - const label = serverCommand.source === "mcp" ? ":mcp" : "" - results.push({ - display: "/" + serverCommand.name + label, - description: serverCommand.description, - onSelect: () => { - const newText = "/" + serverCommand.name + " " - const cursor = props.input().logicalCursor - props.input().deleteRange(0, 0, cursor.row, cursor.col) - props.input().insertText(newText) - props.input().cursorOffset = Bun.stringWidth(newText) - }, - }) + // altimate_change — first run: hide server-defined commands (/discover etc.) + // until a model is ready; local slash actions (e.g. /connect) above stay visible. + if (ready()) { + for (const serverCommand of sync.data.command) { + if (serverCommand.source === "skill") continue + // altimate_change start — keep one autocomplete row per slash command name + if (localSlashNames.has(serverCommand.name)) continue + // altimate_change end + // altimate_change — onboard-connect is invoked programmatically by the Part 2 + // scan gate, not typed; keep it out of the slash menu. + if (serverCommand.name === "onboard-connect") continue + const label = serverCommand.source === "mcp" ? ":mcp" : "" + results.push({ + display: "/" + serverCommand.name + label, + description: serverCommand.description, + onSelect: () => { + const newText = "/" + serverCommand.name + " " + const cursor = props.input().logicalCursor + props.input().deleteRange(0, 0, cursor.row, cursor.col) + props.input().insertText(newText) + props.input().cursorOffset = Bun.stringWidth(newText) + }, + }) + } } results.sort((a, b) => a.display.localeCompare(b.display)) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 176c27ec2..f41079458 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -16,7 +16,6 @@ import { fileURLToPath } from "url" import { useLocal } from "../../context/local" import { Flag } from "@opencode-ai/core/flag/flag" import { tint, useTheme } from "../../context/theme" -import { EmptyBorder, SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { useClipboard } from "../../context/clipboard" import { Spinner } from "../spinner" @@ -43,7 +42,10 @@ import { errorMessage } from "../../util/error" import { formatDuration } from "../../util/format" import { createColors, createFrames } from "../../ui/spinner" import { useDialog } from "../../ui/dialog" -import { DialogProvider as DialogProviderConnect } from "../dialog-provider" +import { DialogProvider as DialogProviderConnect, WARNLIST } from "../dialog-provider" +// altimate_change — first-run submit gate: open the curated welcome picker instead +// of erroring when no model is ready yet (see altimate-onboarding.tsx). +import { DialogModelWelcome, useReady } from "../altimate-onboarding" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" @@ -246,6 +248,11 @@ export function Prompt(props: PromptProps) { const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) + // altimate_change — readiness gate: guide (don't block) when no provider is set up + const ready = useReady() + // altimate_change — known-bad tool-callers get a persistent "⚠ unreliable model" chip + // in the prompt meta row (same WARNLIST the model picker warns with). + const unreliableModel = createMemo(() => Boolean(WARNLIST[local.model.parsed().model])) function promptModelWarning() { toast.show({ @@ -1036,6 +1043,19 @@ export function Prompt(props: PromptProps) { void exit() return true } + // altimate_change start — first-run gate without dead chat / error copy: a normal + // message with no provider ready opens the welcome picker (the message is + // discarded) with a friendly line, rather than erroring. + if (!ready()) { + dialog.replace(() => ( + + )) + input.clear() + input.extmarks.clear() + setStore("prompt", { input: "", parts: [] }) + return false + } + // altimate_change end const selectedModel = local.model.current() if (!selectedModel) { void promptModelWarning() @@ -1440,26 +1460,26 @@ export function Prompt(props: PromptProps) { return ( <> (anchor = r)} visible={props.visible !== false} width="100%"> - + {/* altimate_change start — Claude-Code-style input bar: thin rule above and + below, "›" prompt char, no filled background; agent/model hints move to a + line under the bottom rule */} + + + › +