From 51bb3ece1f34700b844acabe4bc347519fe79368 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 10 Jul 2026 14:51:54 +0700 Subject: [PATCH 01/12] feat: standalone gateway configure flow (SSO or API key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone installs had no way to set up the CoDev AI gateway — the hub's `codevhub config` normally writes ~/.config/codev-code/ opencode.json for them, and upstream's provider dialog stops at " configure it in opencode.json yourself" for custom providers. New fork-only packages/codev-gateway ports the hub's non-UI machinery: PKCE SSO login against the CoDev wrapper (loopback server, browser open, manual paste-back, silent refresh, CODEV_BYPASS_LOGIN test hatch), backend calls (/auth/exchange, /config, /v1/models, /key/info), the hub-shared ~/.codev/auth.json read-merge-write, and a builder for the exact provider.aigateway config fragment the hub writes. The TUI gains DialogConfigure (fork-only component): choose "Login with SSO" or "I have my own API key", pick a default model from the gateway (MiniMax fallback when the list can't be fetched), then persist auth.json and PATCH the global config through the SDK so the provider registry refreshes in place. It replaces upstream's empty-provider dialog on first run (upstream's /connect remains the escape hatch), is available anytime as /configure, and `codev configure` opens the TUI with the dialog up. Co-Authored-By: Claude Fable 5 --- DIVERGENCES.md | 7 +- bun.lock | 10 + packages/codev-gateway/package.json | 17 + packages/codev-gateway/src/auth-file.ts | 155 +++++++ packages/codev-gateway/src/backend.ts | 103 +++++ packages/codev-gateway/src/config-block.ts | 48 ++ packages/codev-gateway/src/const.ts | 20 + packages/codev-gateway/src/index.ts | 15 + packages/codev-gateway/src/sso.ts | 414 ++++++++++++++++++ packages/codev-gateway/test/auth-file.test.ts | 72 +++ .../codev-gateway/test/config-block.test.ts | 47 ++ packages/codev-gateway/test/sso.test.ts | 25 ++ packages/codev-gateway/tsconfig.json | 8 + packages/opencode/src/cli/cmd/configure.ts | 19 + packages/opencode/src/index.ts | 2 + .../__snapshots__/help-snapshots.test.ts.snap | 13 + .../test/cli/help/help-snapshots.test.ts | 1 + packages/tui/package.json | 5 +- packages/tui/src/app.tsx | 21 +- .../tui/src/component/dialog-configure.tsx | 216 +++++++++ 20 files changed, 1212 insertions(+), 6 deletions(-) create mode 100644 packages/codev-gateway/package.json create mode 100644 packages/codev-gateway/src/auth-file.ts create mode 100644 packages/codev-gateway/src/backend.ts create mode 100644 packages/codev-gateway/src/config-block.ts create mode 100644 packages/codev-gateway/src/const.ts create mode 100644 packages/codev-gateway/src/index.ts create mode 100644 packages/codev-gateway/src/sso.ts create mode 100644 packages/codev-gateway/test/auth-file.test.ts create mode 100644 packages/codev-gateway/test/config-block.test.ts create mode 100644 packages/codev-gateway/test/sso.test.ts create mode 100644 packages/codev-gateway/tsconfig.json create mode 100644 packages/opencode/src/cli/cmd/configure.ts create mode 100644 packages/tui/src/component/dialog-configure.tsx diff --git a/DIVERGENCES.md b/DIVERGENCES.md index eb2babcd7e..439069ae39 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -16,10 +16,11 @@ upstream maintainers — see README → Syncing with upstream for why and how. | File | Change | Why | On conflict | | --- | --- | --- | --- | | `packages/core/src/global.ts`, `packages/core/test/global.test.ts`, `packages/opencode/test/cli/mcp-add.test.ts` | `const app = "opencode"` → `"codev-code"`; test expectations `tmpdir()/opencode` → `tmpdir()/codev-code` and `.config/opencode/` → `.config/codev-code/` | Config/data/cache/state dirs under `~/.config/codev-code` etc.; upstream tests assert the literal dir name. | Keep ours (the one constant + expectations); take upstream's surrounding changes. | -| `packages/opencode/src/index.ts`, `packages/opencode/test/cli/error.test.ts`, `packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap` | `scriptName("opencode")` → `"codev"`; `show()` prefix check `"opencode "` → `"codev "`; test expectation `` `opencode models` `` → `` `codev models` ``; help snapshots regenerated so usage lines read `codev …` | Help/usage text shows the real user-facing command (the `codev` hub forwards unknown commands to this binary). The `show()` check must always match the scriptName; upstream's error-format test and help snapshots assert the literal command. | Keep our strings + expectation; take upstream's surrounding changes. Regenerate the snapshot file with `bun test test/cli/help --update-snapshots` instead of hand-merging. | +| `packages/opencode/src/index.ts`, `packages/opencode/test/cli/error.test.ts`, `packages/opencode/test/cli/help/help-snapshots.test.ts`, `packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap` | `scriptName("opencode")` → `"codev"`; `show()` prefix check `"opencode "` → `"codev "`; registered the fork-only `ConfigureCommand` (cli/cmd/configure.ts, new file) and added `configure` to the help-snapshot TOP_LEVEL list; test expectation `` `opencode models` `` → `` `codev models` ``; help snapshots regenerated so usage lines read `codev …` | Help/usage text shows the real user-facing command; `codev configure` is the standalone gateway-setup entry point. The `show()` check must always match the scriptName; upstream's error-format test and help snapshots assert the literal command. | Keep our strings, command registration + expectation; take upstream's surrounding changes. Regenerate the snapshot file with `bun test test/cli/help --update-snapshots` instead of hand-merging. | | `package.json` (root) | Added `release` script delegating to packages/opencode | `bun run release` works from the repo root. | Keep our release line; take upstream's other fields. | | `packages/opencode/package.json` | bin key `opencode` → `codev` (shim path `./bin/opencode` unchanged); added `release` script | Installed command is `codev` — matches the CLI's `scriptName` and help text (the hub's command is `codevhub`; it relinquished the `codev` bin in codev-ai ≥ 0.4.0). `bun run release` publishes the version in this file (upstream-synced; script/release.ts is fork-only). | Keep our bin key and release line; take upstream's other fields including `version`. | -| `bun.lock` | Mirrors the bin key rename above | Lockfile records workspace bin mappings. | Regenerate with `bun install`; never hand-merge. | +| `bun.lock` | Mirrors the bin key rename above and the fork-only workspace packages/deps (`@codev/gateway`) | Lockfile records workspace bin mappings and workspace deps. | Regenerate with `bun install`; never hand-merge. | +| `packages/tui/package.json` | Added dependency `"@codev/gateway": "workspace:*"` | The fork's gateway configure dialog imports SSO/backend/config-block logic from the fork-only `packages/codev-gateway`. | Keep our dependency line; take upstream's other fields. | | `packages/opencode/script/build.ts` | Platform packages' `package.json` name `codev-code-` (dist dir names unchanged — the bun compile target derives from them) | npm publishing under the fork's names. | Keep our `name:` line; take upstream's surrounding changes. | | `packages/opencode/script/publish.ts` | `PUBLISH_NAME = "codev-code"` const; npm description "CoDev Code — the AI agent harness"; published-name→dir map; main pkg name + bin key `codev`; embedded README (hub via `codevhub install`, direct install runs `codev`); env-guarded exit before the Docker/AUR/Homebrew section | npm-only publishing under the fork's names (unscoped, public on npmjs); the installed command is `codev`; upstream's other channels target anomalyco infra. | Keep our names, description, bin key + early exit; take upstream's npm-section changes above the exit. | | `packages/opencode/bin/opencode` | `base` prefix `opencode-` → `codev-code-` | Runtime platform-binary lookup must match the published names. | Keep our prefix. | @@ -30,7 +31,7 @@ upstream maintainers — see README → Syncing with upstream for why and how. | `packages/opencode/src/cli/ui.ts` | `wordmark`/`logo()` render the hub-style CODEV banner (bold bright cyan on TTY) | Help banner matches the hub CLI banner. | Keep our wordmark + logo(); take upstream's surrounding changes. | | `packages/tui/src/util/presentation.ts` | Local logo copy replaced by the shared CODEV `banner` (cyan) | Presentation-mode wordmark was still upstream-branded. | Keep our wordmark(); take upstream's surrounding changes. | | `packages/opencode/src/cli/cmd/*.ts` (tui, run, serve, attach, pr, upgrade, uninstall, web) | Help `describe` strings "opencode" → "codev" | `--help` is the first thing users read. | Keep our strings. | -| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|" | Tab title branding. | Keep our strings. | +| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; empty-provider startup dialog swapped from `DialogProviderList` to the fork's `DialogConfigure` (component/dialog-configure.tsx, new file); added the `codev.configure` command (`/configure`) and a `CODEV_CONFIGURE=1` mount hook for `codev configure` | Tab title branding; first-run setup routes to the CoDev gateway configure flow (upstream's `/connect` stays as the escape hatch). | Keep our strings, dialog swap + command block; take upstream's surrounding changes. | | `packages/tui/src/routes/session/permission.tsx`, `packages/opencode/src/cli/cmd/run/permission.shared.ts`, `packages/opencode/test/cli/run/permission.shared.test.ts`, `packages/tui/src/component/error-component.tsx`, `packages/opencode/src/cli/cmd/run/splash.ts` | Visible sentences/badges "OpenCode"/"opencode" → "CoDev Code"/"codev-code" (incl. the permission always-allow copy and its test expectations) | User-facing copy. | Keep our strings. | | `packages/opencode/src/installation/index.ts` | Updater detects/queries/installs `codev-code` (npm-family); GitHub fallback → quickbeard/codev-code | `codev-code upgrade` and the update notification target OUR npm package, never stock opencode. brew/choco/scoop probes never match (no such packages). | Keep our package names. | | `packages/opencode/src/config/config.ts` | `share` defaults to `"disabled"` when unset | Sharing publishes sessions to upstream's share service; internal users must opt in via config. | Keep our default block. | diff --git a/bun.lock b/bun.lock index da736b1f19..c1147a3736 100644 --- a/bun.lock +++ b/bun.lock @@ -154,6 +154,13 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/codev-gateway": { + "name": "@codev/gateway", + "version": "0.0.1", + "devDependencies": { + "@types/bun": "catalog:", + }, + }, "packages/console/app": { "name": "@opencode-ai/console-app", "version": "1.17.16", @@ -946,6 +953,7 @@ "name": "@opencode-ai/tui", "version": "1.17.16", "dependencies": { + "@codev/gateway": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -1468,6 +1476,8 @@ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="], + "@codev/gateway": ["@codev/gateway@workspace:packages/codev-gateway"], + "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], diff --git a/packages/codev-gateway/package.json b/packages/codev-gateway/package.json new file mode 100644 index 0000000000..7773a5132f --- /dev/null +++ b/packages/codev-gateway/package.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/package", + "name": "@codev/gateway", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "catalog:" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/codev-gateway/src/auth-file.ts b/packages/codev-gateway/src/auth-file.ts new file mode 100644 index 0000000000..d733c790eb --- /dev/null +++ b/packages/codev-gateway/src/auth-file.ts @@ -0,0 +1,155 @@ +import { randomBytes } from "node:crypto" +import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { dirname, join } from "node:path" + +export interface AuthData { + access_token: string + id_token: string + refresh_token?: string + expires_at: number + user: { + sub: string + email: string + displayName: string + } +} + +export interface ApiKeyCreds { + apiKey: string + baseUrl?: string + model?: string +} + +export interface CodevConfig { + supabaseUrl: string + supabaseAnonKey: string + gatewayUrl: string +} + +// ~/.codev/auth.json is SHARED with the codev hub: identical path and shape +// (hub src/lib/auth.ts AuthFileContents), so a hub installed later reuses the +// session this flow created and vice versa. Three independent blocks — SSO +// tokens, gateway API key, CoDev runtime config — each updated in isolation so +// saving one never clobbers the others. skillhub_cookie is hub-only; the +// read-merge-write pattern preserves it untouched. +interface AuthFileContents { + access_token?: string + id_token?: string + refresh_token?: string + expires_at?: number + user?: AuthData["user"] + api_key?: string + base_url?: string + model?: string + supabase_url?: string + supabase_anon_key?: string + gateway_url?: string + skillhub_cookie?: string +} + +export function authFilePath() { + return join(homedir(), ".codev", "auth.json") +} + +// Written by the hub's `codevhub logout` (revoking tokens doesn't end the +// IdP's browser session); honored here so a post-logout login re-prompts for +// credentials instead of silently riding the session cookie. +export function forceLoginPath() { + return join(homedir(), ".codev", "force-login") +} + +export function readAuthFile(): AuthFileContents | null { + try { + return JSON.parse(readFileSync(authFilePath(), "utf-8")) as AuthFileContents + } catch { + return null + } +} + +function writeAuthFile(data: AuthFileContents): void { + const path = authFilePath() + const dir = dirname(path) + mkdirSync(dir, { recursive: true, mode: 0o700 }) + // mkdirSync's mode is ignored when the directory already exists, and + // writeFileSync's mode is ignored when the file already exists, so re-apply + // permissions explicitly to tighten any pre-existing loose perms. + chmodSync(dir, 0o700) + // Atomic write: stage to a per-process tmp sibling, chmod 0600 before rename + // so the published file never appears world-readable, then rename over the + // live path — concurrent writers (the hub's daemon, another codev process) + // end up with one whole write or the other, never a torn merge. + const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp` + try { + writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }) + chmodSync(tmp, 0o600) + renameSync(tmp, path) + } catch (err) { + try { + unlinkSync(tmp) + } catch { + // tmp may already be absent. + } + throw err + } + // Re-apply mode after rename for the overwrite case where the renamed inode + // inherits the destination's prior mode on some platforms. + chmodSync(path, 0o600) +} + +export function loadAuth(): AuthData | null { + const raw = readAuthFile() + if (!raw) return null + if (!raw.access_token || !raw.id_token || !raw.expires_at || !raw.user) return null + if (Date.now() > raw.expires_at) return null + return { + access_token: raw.access_token, + id_token: raw.id_token, + refresh_token: raw.refresh_token, + expires_at: raw.expires_at, + user: raw.user, + } +} + +export function saveAuth(data: AuthData): void { + writeAuthFile({ + ...(readAuthFile() ?? {}), + access_token: data.access_token, + id_token: data.id_token, + refresh_token: data.refresh_token, + expires_at: data.expires_at, + user: data.user, + }) +} + +export function saveApiKey(creds: ApiKeyCreds): void { + writeAuthFile({ + ...(readAuthFile() ?? {}), + api_key: creds.apiKey, + base_url: creds.baseUrl, + model: creds.model, + }) +} + +export function loadApiKey(): ApiKeyCreds | null { + const raw = readAuthFile() + if (!raw?.api_key) return null + return { + apiKey: raw.api_key, + baseUrl: raw.base_url, + model: raw.model, + } +} + +export function saveCodevConfig(config: CodevConfig): void { + writeAuthFile({ + ...(readAuthFile() ?? {}), + supabase_url: config.supabaseUrl, + supabase_anon_key: config.supabaseAnonKey, + gateway_url: config.gatewayUrl, + }) +} + +export function loadGatewayUrl(): string | null { + return readAuthFile()?.gateway_url ?? null +} diff --git a/packages/codev-gateway/src/backend.ts b/packages/codev-gateway/src/backend.ts new file mode 100644 index 0000000000..4109305f72 --- /dev/null +++ b/packages/codev-gateway/src/backend.ts @@ -0,0 +1,103 @@ +import { BACKEND_URL } from "./const" +import type { CodevConfig } from "./auth-file" + +interface ErrorResponse { + error?: string +} + +const VALIDATE_TIMEOUT_MS = 5_000 +const MODELS_TIMEOUT_MS = 10_000 +// Backend endpoints are quick: a token exchange and a tiny config blob. Cap +// them so a stalled gateway surfaces as an error instead of wedging the TUI. +const BACKEND_TIMEOUT_MS = 10_000 + +// SSO access token → gateway API key (backend /auth/exchange). An empty key is +// returned, not thrown — callers route to the manual-credentials fallback. +export async function fetchApiKey(accessToken: string): Promise { + const res = await fetch(`${BACKEND_URL}/auth/exchange`, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(BACKEND_TIMEOUT_MS), + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as ErrorResponse + throw new Error(`Backend /auth/exchange failed (${res.status}): ${body.error || res.statusText}`) + } + const data = (await res.json()) as { api_key?: string } + return data.api_key ?? "" +} + +// Pulls the runtime coordinates that are not baked into the binary — most +// importantly the public gateway base URL. Persisted next to the SSO session +// via saveCodevConfig, where the hub also reads it. +export async function fetchCodevConfig(accessToken: string): Promise { + const res = await fetch(`${BACKEND_URL}/config`, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(BACKEND_TIMEOUT_MS), + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as ErrorResponse + throw new Error(`Backend /config failed (${res.status}): ${body.error || res.statusText}`) + } + const data = (await res.json()) as Partial<{ supabaseUrl: string; supabaseAnonKey: string; gatewayUrl: string }> + if (!data.supabaseUrl || !data.supabaseAnonKey || !data.gatewayUrl) { + throw new Error(`Backend /config returned incomplete payload: ${JSON.stringify(data)}`) + } + return { + supabaseUrl: data.supabaseUrl, + supabaseAnonKey: data.supabaseAnonKey, + gatewayUrl: data.gatewayUrl, + } +} + +// Build a `/v1/` gateway URL. Manual base URLs may or may not +// carry a /v1 suffix — normalize so exactly one /v1 segment precedes the +// suffix either way. +function gatewayV1Url(baseUrl: string, suffix: string): string { + const base = baseUrl.replace(/\/$/, "") + const withV1 = /\/v1$/.test(base) ? base : `${base}/v1` + return `${withV1}/${suffix}` +} + +// Fetches the model ids available to the given key via the OpenAI-compatible +// /v1/models endpoint. Throws on non-2xx, network errors, timeout, or an +// empty result — the caller decides whether to fall back. +export async function fetchModels(apiKey: string, baseUrl: string): Promise { + const res = await fetch(gatewayV1Url(baseUrl, "models"), { + headers: { accept: "application/json", Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(MODELS_TIMEOUT_MS), + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as ErrorResponse + throw new Error(`Models fetch failed (${res.status}): ${body.error || res.statusText}`) + } + const data = (await res.json()) as { data?: Array<{ id?: string }> } + const ids = (data.data ?? []).map((m) => m.id).filter((id): id is string => typeof id === "string" && id.length > 0) + if (ids.length === 0) throw new Error("Gateway returned no models") + return ids +} + +// Detects whether a thrown fetchModels error was caused by an invalid key +// (401/403) as opposed to a network/5xx/timeout — the invalid-key case should +// re-prompt for credentials, not silently fall back to a default model. +export function isInvalidKeyError(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err) + return /Models fetch failed \((?:401|403)\)/.test(msg) +} + +// Validates a key against the gateway's /key/info endpoint (LiteLLM): true on +// 2xx, false on 401/403 (invalid/revoked), throws on anything else so callers +// can distinguish "key is bad" from "couldn't reach the gateway". /key/info +// lives at the gateway root, so a trailing /v1 is stripped before joining. +export async function validateApiKey(apiKey: string, baseUrl: string): Promise { + const stripped = baseUrl.replace(/\/?v1\/?$/, "") + const url = `${stripped.endsWith("/") ? stripped : `${stripped}/`}key/info` + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(VALIDATE_TIMEOUT_MS), + }) + if (res.status === 401 || res.status === 403) return false + if (!res.ok) throw new Error(`Validation failed (${res.status}): ${res.statusText}`) + return true +} diff --git a/packages/codev-gateway/src/config-block.ts b/packages/codev-gateway/src/config-block.ts new file mode 100644 index 0000000000..df088e613d --- /dev/null +++ b/packages/codev-gateway/src/config-block.ts @@ -0,0 +1,48 @@ +import { GATEWAY_COMPACT_RESERVED, GATEWAY_CONTEXT_WINDOW, GATEWAY_MAX_OUTPUT_TOKENS } from "./const" + +// The exact opencode.json fragment the codev hub writes for CoDev Code (hub +// src/lib/configure.ts configureOpenCodeKind), so hub-managed and +// self-configured installs are indistinguishable — including the hub's +// `provider.aigateway` presence check that marks a config as CoDev-managed. +// The per-model `limit` is mandatory: without it opencode sizes the context +// window to 0 and disables auto-compaction. +export function buildGatewayConfig(input: { apiKey: string; baseUrl: string; model: string; models?: string[] }) { + const ids = [...new Set([input.model, ...(input.models ?? [])])] + return { + model: `aigateway/${input.model}`, + compaction: { + auto: true, + reserved: GATEWAY_COMPACT_RESERVED, + }, + provider: { + aigateway: { + npm: "@ai-sdk/openai-compatible", + name: "AI Gateway", + options: { + baseURL: normalizeGatewayBaseUrl(input.baseUrl), + apiKey: input.apiKey, + }, + models: Object.fromEntries( + ids.map((id) => [ + id, + { + name: id, + limit: { + context: GATEWAY_CONTEXT_WINDOW, + output: GATEWAY_MAX_OUTPUT_TOKENS, + }, + }, + ]), + ), + }, + }, + } +} + +// opencode's openai-compatible loader wants the /v1 root in baseURL; users +// paste the gateway origin with or without it — normalize to exactly one +// trailing /v1 (hub normalizeOpenCodeBaseUrl). +export function normalizeGatewayBaseUrl(base: string): string { + const stripped = base.replace(/\/$/, "") + return /\/v1$/.test(stripped) ? stripped : `${stripped}/v1` +} diff --git a/packages/codev-gateway/src/const.ts b/packages/codev-gateway/src/const.ts new file mode 100644 index 0000000000..7ac7731d0f --- /dev/null +++ b/packages/codev-gateway/src/const.ts @@ -0,0 +1,20 @@ +// CoDev backend coordinates, mirrored from the codev hub's src/lib/const.ts so +// a standalone CoDev Code install can configure the gateway without the hub. +// Base64 (like the hub) to keep the host out of casual greps, not as a secret. +const BASE_URL = atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4=") +export const BACKEND_URL = `${BASE_URL}/codev-backend` +export const SSO_URL = `${BASE_URL}/sso-wrapper` +export const LOGIN_SUCCESS_URL = `${BASE_URL}/codev-landing-page/oauth/success` + +export const CLIENT_ID = atob("bGl0ZWxsbS10ZXN0") + +export const FALLBACK_MODEL = atob("TWluaU1heC9NaW5pTWF4LU0yLjc=") + +// The self-hosted gateway model window and the compaction reserve derived from +// firing auto-compaction at ~85% of it — identical to the hub's constants so a +// hub-written and an agent-written opencode.json agree byte-for-byte. +export const GATEWAY_CONTEXT_WINDOW = 196608 +export const GATEWAY_COMPACT_PCT = 85 +export const GATEWAY_COMPACT_TRIGGER = Math.round(GATEWAY_CONTEXT_WINDOW * (GATEWAY_COMPACT_PCT / 100)) +export const GATEWAY_COMPACT_RESERVED = GATEWAY_CONTEXT_WINDOW - GATEWAY_COMPACT_TRIGGER +export const GATEWAY_MAX_OUTPUT_TOKENS = 65536 diff --git a/packages/codev-gateway/src/index.ts b/packages/codev-gateway/src/index.ts new file mode 100644 index 0000000000..011199de28 --- /dev/null +++ b/packages/codev-gateway/src/index.ts @@ -0,0 +1,15 @@ +export { + authFilePath, + loadApiKey, + loadAuth, + loadGatewayUrl, + saveApiKey, + saveCodevConfig, + type ApiKeyCreds, + type AuthData, + type CodevConfig, +} from "./auth-file" +export { fetchApiKey, fetchCodevConfig, fetchModels, isInvalidKeyError, validateApiKey } from "./backend" +export { buildGatewayConfig, normalizeGatewayBaseUrl } from "./config-block" +export { FALLBACK_MODEL } from "./const" +export { login, parseCallbackParams, silentSso, type LoginOptions, type OnLoginReady } from "./sso" diff --git a/packages/codev-gateway/src/sso.ts b/packages/codev-gateway/src/sso.ts new file mode 100644 index 0000000000..755b815bed --- /dev/null +++ b/packages/codev-gateway/src/sso.ts @@ -0,0 +1,414 @@ +import { existsSync, unlinkSync } from "node:fs" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { authFilePath, forceLoginPath, loadAuth, readAuthFile, saveAuth, type AuthData } from "./auth-file" +import { CLIENT_ID, LOGIN_SUCCESS_URL, SSO_URL } from "./const" + +// Token/userinfo endpoints are quick handshakes — cap them so a hung IdP +// surfaces as an error instead of wedging the configure dialog indefinitely. +const SSO_FETCH_TIMEOUT_MS = 10_000 +// How long we wait for the authorization code — via the loopback callback or +// a manual paste-back. Generous because a no-browser user may be hopping to +// another device to authenticate and copy the URL back by hand. +const AUTH_CALLBACK_TIMEOUT_MS = 300_000 + +interface TokenResponse { + access_token: string + id_token: string + refresh_token?: string + expires_in: number +} + +// How login() hands the interactive step back to its caller: a function that +// opens the browser, the directly-pasteable /authorize URL for no-browser +// environments, and a manual paste-back submitter that returns an inline error +// string to re-prompt (or null once the code is accepted — the login() promise +// then resolves on its own). +export type OnLoginReady = ( + openBrowserFn: () => void, + authUrl: string, + submitManualCode: (pasted: string) => string | null, +) => void + +export interface LoginOptions { + onLog: (msg: string) => void + onReady: OnLoginReady + // Browser launcher; the TUI passes its own `open` util. Fire-and-forget — + // the loopback server resolves the flow whether or not a browser launched. + openUrl: (url: string) => Promise +} + +/** + * OAuth2 Authorization Code flow with PKCE against the CoDev SSO wrapper, + * ported from the hub (src/lib/auth.ts) so both tools share one session in + * ~/.codev/auth.json: + * 1. Reuse cached tokens if still valid + * 2. Try a silent refresh if a refresh_token is on disk + * 3. Otherwise: loopback HTTP server, /authorize with state + nonce + PKCE, + * wait for the callback (or manual paste), exchange code for tokens, fetch + * userinfo, persist. + */ +export async function login(options: LoginOptions): Promise { + const log = options.onLog + log("Starting SSO login...") + + // Dev escape hatch (hub parity): skip OAuth and persist a stub session so + // the rest of the flow can be exercised while the SSO wrapper is down. + if (process.env.CODEV_BYPASS_LOGIN === "1") { + log("CODEV_BYPASS_LOGIN=1 — skipping SSO, using stub session.") + const authData: AuthData = { + access_token: "codev-bypass-no-sso", + id_token: "codev-bypass-no-sso", + expires_at: Date.now() + 3_600_000, + user: { sub: "codev-bypass", email: "bypass@local", displayName: "Bypass User" }, + } + saveAuth(authData) + clearForceLogin() + log(`Logged in as ${authData.user.email}`) + return authData + } + + const existing = loadAuth() + if (existing) { + log(`Already logged in as ${existing.user.email}`) + return existing + } + + const stale = readAuthFile() + if (stale?.refresh_token) { + try { + log("Refreshing session...") + const authData = await refreshToAuthData(stale.refresh_token) + saveAuth(authData) + log(`Logged in as ${authData.user.email}`) + return authData + } catch { + log("Refresh failed, starting full login...") + } + } + + // Force re-auth via the IdP login form (prompt=login bounce) when there is + // no auth.json at all (fresh machine / post-`codevhub remove`) or the hub's + // logout left its force-login sentinel — revoking tokens does not terminate + // the IdP's browser-session cookie. + const forceLogin = !existsSync(authFilePath()) || existsSync(forceLoginPath()) + + const verifier = generateCodeVerifier() + const challenge = await generateCodeChallenge(verifier) + const state = crypto.randomUUID() + const nonce = crypto.randomUUID() + + const { code, redirectUri } = await getAuthCode(options, state, challenge, nonce, forceLogin) + + const tokenRes = await exchangeCode(code, redirectUri, verifier) + const user = await fetchUserInfo(tokenRes.access_token) + + const authData: AuthData = { + access_token: tokenRes.access_token, + id_token: tokenRes.id_token, + refresh_token: tokenRes.refresh_token, + expires_at: Date.now() + tokenRes.expires_in * 1000, + user: { + sub: user.sub, + email: user.email, + displayName: user.displayName || user.name || user.sub, + }, + } + + saveAuth(authData) + clearForceLogin() + log(`Logged in as ${authData.user.email}`) + return authData +} + +// Non-interactive SSO: reuse a non-expired cached session, else silently +// refresh via the stored refresh_token. Returns null when neither works — +// callers then run the interactive login(). +export async function silentSso(): Promise { + const cached = loadAuth() + if (cached) return cached + const stale = readAuthFile() + if (!stale?.refresh_token) return null + try { + const authData = await refreshToAuthData(stale.refresh_token) + saveAuth(authData) + return authData + } catch { + return null + } +} + +async function refreshToAuthData(refreshToken: string): Promise { + const refreshed = await refreshTokens(refreshToken) + const user = await fetchUserInfo(refreshed.access_token) + return { + access_token: refreshed.access_token, + id_token: refreshed.id_token, + refresh_token: refreshed.refresh_token || refreshToken, + expires_at: Date.now() + refreshed.expires_in * 1000, + user: { + sub: user.sub, + email: user.email, + displayName: user.displayName || user.name || user.sub, + }, + } +} + +function clearForceLogin() { + try { + unlinkSync(forceLoginPath()) + } catch { + // Fine if it didn't exist. + } +} + +function base64UrlEncode(bytes: Uint8Array): string { + let str = "" + for (const b of bytes) str += String.fromCharCode(b) + return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +function generateCodeVerifier(): string { + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + return base64UrlEncode(bytes) +} + +async function generateCodeChallenge(verifier: string): Promise { + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)) + return base64UrlEncode(new Uint8Array(hash)) +} + +// Extracts OAuth callback params from pasted text: a full callback URL, a +// scheme-less "127.0.0.1:PORT/callback?...", a bare query string, or a leading +// "?". Returns null when the paste carries no recognizable params — the caller +// then treats it as a bare authorization code. +export function parseCallbackParams(pasted: string): URLSearchParams | null { + try { + return new URL(pasted).searchParams + } catch { + // Not a full URL — fall through. + } + const query = pasted.includes("?") ? pasted.slice(pasted.indexOf("?") + 1) : pasted + if (/(?:^|&)(?:code|state|error)=/.test(query)) { + return new URLSearchParams(query) + } + return null +} + +function getAuthCode( + options: LoginOptions, + expectedState: string, + codeChallenge: string, + nonce: string, + forceLogin: boolean, +): Promise<{ code: string; redirectUri: string }> { + return new Promise((resolve, reject) => { + let timeoutHandle: ReturnType | null = null + // Set once the flow resolves or rejects — by the loopback callback or by a + // manual paste — so the two paths can't both fire. + let settled = false + const finish = () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle) + timeoutHandle = null + } + } + + // Captured once when listen() completes — server.address() returns null + // after close(), and a stray keep-alive request can arrive that late. + let boundPort = 0 + + // Two authorize targets, mirroring loopback OAuth CLIs (gh, gcloud): the + // local browser is sent straight to the loopback callback; the manual + // paste-back channel gets the public success page, which displays the code + // to copy when the terminal is on another machine. The wrapper keys the + // code to PKCE, so the token exchange can use the loopback redirect_uri + // for either. + const buildAuthorizeUrl = (redirectUri: string) => + `${SSO_URL}/authorize?` + + `response_type=code` + + `&client_id=${encodeURIComponent(CLIENT_ID)}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&scope=openid%20profile%20email%20offline_access` + + `&state=${expectedState}` + + `&nonce=${nonce}` + + `&code_challenge=${codeChallenge}` + + `&code_challenge_method=S256` + + const loopbackAuthorizeUrl = (port: number) => buildAuthorizeUrl(`http://127.0.0.1:${port}/callback`) + const manualAuthorizeUrl = buildAuthorizeUrl(LOGIN_SUCCESS_URL) + + const succeed = (code: string) => { + if (settled) return + settled = true + finish() + server.close() + resolve({ code, redirectUri: `http://127.0.0.1:${boundPort}/callback` }) + } + + const failWith = (err: Error) => { + if (settled) return + settled = true + finish() + server.close() + reject(err) + } + + // Manual paste-back for no-browser environments: accepts a full URL, a + // bare query string, or a bare code. Returns an inline error string to + // re-prompt without restarting, or null once the code is accepted. + const submitManualCode = (pasted: string): string | null => { + if (settled) return null + const trimmed = pasted.trim() + if (!trimmed) return "Paste the callback URL (or code) first." + + const params = parseCallbackParams(trimmed) + const errParam = params?.get("error") + if (errParam) { + return `SSO returned an error: ${params?.get("error_description") || errParam}` + } + + // No params → treat the whole paste as a bare authorization code. There + // is no state to cross-check, but PKCE still binds it to our verifier at + // the token exchange. + const code = params ? params.get("code") : trimmed + if (!code) return "No authorization code found in the pasted URL." + const returnedState = params?.get("state") ?? null + if (returnedState !== null && returnedState !== expectedState) { + return "That URL is from a different login attempt (state mismatch). Use the most recent URL." + } + + succeed(code) + return null + } + + const server = createServer((req, res) => { + const host = req.headers.host ?? "127.0.0.1" + const url = new URL(req.url ?? "/", `http://${host}`) + + // force-login only: the IdP has just killed its session cookie and + // redirected back here — bounce on to /authorize so it shows the + // credential form. + if (url.pathname === "/logout-done") { + res.writeHead(302, { Location: loopbackAuthorizeUrl(boundPort) }) + res.end() + return + } + + if (url.pathname !== "/callback") { + res.writeHead(404, { "Content-Type": "text/plain" }) + res.end("Not found") + return + } + + const code = url.searchParams.get("code") + const error = url.searchParams.get("error") + const returnedState = url.searchParams.get("state") + + // Hand the browser on to the hosted success page as the user-facing + // confirmation; failures carry ?error=... so the page renders an error. + const respond = (ok: boolean, msg?: string) => { + const sep = LOGIN_SUCCESS_URL.includes("?") ? "&" : "?" + const location = ok + ? LOGIN_SUCCESS_URL + : `${LOGIN_SUCCESS_URL}${sep}error=login_failed&error_description=${encodeURIComponent(msg ?? "Unknown error")}` + res.writeHead(302, { Location: location }) + res.end() + } + + if (error) { + const desc = url.searchParams.get("error_description") || error + respond(false, desc) + failWith(new Error(`SSO login failed: ${desc}`)) + return + } + if (!code) { + respond(false, "No authorization code received") + failWith(new Error("No authorization code received")) + return + } + if (returnedState !== expectedState) { + respond(false, "State mismatch") + failWith(new Error("State mismatch (possible CSRF attack)")) + return + } + + respond(true) + succeed(code) + }) + + server.listen(0, "127.0.0.1", () => { + boundPort = (server.address() as AddressInfo).port + // Local browser: loopback authorize (on force-login via the /logout + // bounce that clears the IdP cookie first). Manual channel: the public + // success-page authorize URL a remote browser can complete. + const initialUrl = forceLogin + ? `${SSO_URL}/logout?redirect_uri=${encodeURIComponent(`http://127.0.0.1:${boundPort}/logout-done`)}` + : loopbackAuthorizeUrl(boundPort) + + // Arm the timeout before handing control to the caller so a caller that + // settles synchronously clears a real timer instead of leaking one. + timeoutHandle = setTimeout(() => { + timeoutHandle = null + failWith(new Error("Login timed out after 5 minutes")) + }, AUTH_CALLBACK_TIMEOUT_MS) + + options.onReady( + () => { + options.onLog( + forceLogin ? "Opening browser to end existing SSO session and re-login..." : "Opening browser for SSO login...", + ) + options.openUrl(initialUrl).catch(() => {}) + }, + manualAuthorizeUrl, + submitManualCode, + ) + }) + }) +} + +async function exchangeCode(code: string, redirectUri: string, codeVerifier: string): Promise { + const res = await fetch(`${SSO_URL}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: CLIENT_ID, + code_verifier: codeVerifier, + }), + signal: AbortSignal.timeout(SSO_FETCH_TIMEOUT_MS), + }) + if (!res.ok) { + throw new Error(`Token exchange failed (${res.status}): ${await res.text()}`) + } + return (await res.json()) as TokenResponse +} + +async function refreshTokens(refreshToken: string): Promise { + const res = await fetch(`${SSO_URL}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }), + signal: AbortSignal.timeout(SSO_FETCH_TIMEOUT_MS), + }) + if (!res.ok) throw new Error(`Token refresh failed (${res.status})`) + return (await res.json()) as TokenResponse +} + +async function fetchUserInfo(accessToken: string) { + const res = await fetch(`${SSO_URL}/userinfo`, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(SSO_FETCH_TIMEOUT_MS), + }) + if (!res.ok) { + throw new Error(`Failed to fetch user info (${res.status}): ${await res.text()}`) + } + return (await res.json()) as { sub: string; email: string; displayName?: string; name?: string } +} diff --git a/packages/codev-gateway/test/auth-file.test.ts b/packages/codev-gateway/test/auth-file.test.ts new file mode 100644 index 0000000000..954001b1b8 --- /dev/null +++ b/packages/codev-gateway/test/auth-file.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +let tempDir: string +let originalHome: string | undefined +let originalProfile: string | undefined + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-gateway-auth-")) + originalHome = process.env.HOME + originalProfile = process.env.USERPROFILE + process.env.HOME = tempDir + process.env.USERPROFILE = tempDir +}) + +afterEach(() => { + process.env.HOME = originalHome + process.env.USERPROFILE = originalProfile + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe("auth-file", () => { + test("blocks merge without clobbering each other", async () => { + const { saveApiKey, saveCodevConfig, loadApiKey, authFilePath } = await import("../src/auth-file") + saveApiKey({ apiKey: "sk-1", baseUrl: "https://gw.test", model: "m" }) + saveCodevConfig({ supabaseUrl: "https://sb.test", supabaseAnonKey: "anon", gatewayUrl: "https://gw.test" }) + + expect(loadApiKey()).toEqual({ apiKey: "sk-1", baseUrl: "https://gw.test", model: "m" }) + const raw = JSON.parse(readFileSync(authFilePath(), "utf-8")) + expect(raw.api_key).toBe("sk-1") + expect(raw.gateway_url).toBe("https://gw.test") + }) + + test("preserves unknown fields like the hub's skillhub_cookie", async () => { + const { saveApiKey, authFilePath } = await import("../src/auth-file") + const { mkdirSync, writeFileSync } = await import("node:fs") + mkdirSync(join(tempDir, ".codev"), { recursive: true }) + writeFileSync(authFilePath(), JSON.stringify({ skillhub_cookie: "skill-hub-session=abc" })) + + saveApiKey({ apiKey: "sk-2" }) + const raw = JSON.parse(readFileSync(authFilePath(), "utf-8")) + expect(raw.skillhub_cookie).toBe("skill-hub-session=abc") + expect(raw.api_key).toBe("sk-2") + }) + + test.skipIf(process.platform === "win32")("writes the file 0600", async () => { + const { saveApiKey, authFilePath } = await import("../src/auth-file") + saveApiKey({ apiKey: "sk-3" }) + expect(statSync(authFilePath()).mode & 0o777).toBe(0o600) + }) + + test("loadAuth rejects expired or partial sessions", async () => { + const { loadAuth, saveAuth } = await import("../src/auth-file") + expect(loadAuth()).toBeNull() + saveAuth({ + access_token: "at", + id_token: "it", + expires_at: Date.now() - 1000, + user: { sub: "s", email: "e@x", displayName: "E" }, + }) + expect(loadAuth()).toBeNull() + saveAuth({ + access_token: "at", + id_token: "it", + expires_at: Date.now() + 60_000, + user: { sub: "s", email: "e@x", displayName: "E" }, + }) + expect(loadAuth()?.user.email).toBe("e@x") + }) +}) diff --git a/packages/codev-gateway/test/config-block.test.ts b/packages/codev-gateway/test/config-block.test.ts new file mode 100644 index 0000000000..35821e854c --- /dev/null +++ b/packages/codev-gateway/test/config-block.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import { buildGatewayConfig, normalizeGatewayBaseUrl } from "../src/config-block" + +describe("normalizeGatewayBaseUrl", () => { + test("appends /v1 when missing", () => { + expect(normalizeGatewayBaseUrl("https://gw.test")).toBe("https://gw.test/v1") + }) + + test("keeps an existing /v1 and strips a trailing slash", () => { + expect(normalizeGatewayBaseUrl("https://gw.test/v1")).toBe("https://gw.test/v1") + expect(normalizeGatewayBaseUrl("https://gw.test/v1/")).toBe("https://gw.test/v1") + expect(normalizeGatewayBaseUrl("https://gw.test/")).toBe("https://gw.test/v1") + }) +}) + +describe("buildGatewayConfig", () => { + test("matches the hub-written opencode.json shape", () => { + const config = buildGatewayConfig({ + apiKey: "sk-test", + baseUrl: "https://gw.test", + model: "MiniMax/MiniMax-M2.7", + models: ["MiniMax/MiniMax-M2.7", "other/model"], + }) + expect(config).toEqual({ + model: "aigateway/MiniMax/MiniMax-M2.7", + compaction: { auto: true, reserved: 29491 }, + provider: { + aigateway: { + npm: "@ai-sdk/openai-compatible", + name: "AI Gateway", + options: { baseURL: "https://gw.test/v1", apiKey: "sk-test" }, + models: { + "MiniMax/MiniMax-M2.7": { name: "MiniMax/MiniMax-M2.7", limit: { context: 196608, output: 65536 } }, + "other/model": { name: "other/model", limit: { context: 196608, output: 65536 } }, + }, + }, + }, + }) + }) + + test("falls back to a single-model map and dedupes the default", () => { + const config = buildGatewayConfig({ apiKey: "k", baseUrl: "https://gw.test/v1", model: "m" }) + expect(Object.keys(config.provider.aigateway.models)).toEqual(["m"]) + const withDupe = buildGatewayConfig({ apiKey: "k", baseUrl: "https://gw.test", model: "m", models: ["m", "n"] }) + expect(Object.keys(withDupe.provider.aigateway.models)).toEqual(["m", "n"]) + }) +}) diff --git a/packages/codev-gateway/test/sso.test.ts b/packages/codev-gateway/test/sso.test.ts new file mode 100644 index 0000000000..58e8b8f2dd --- /dev/null +++ b/packages/codev-gateway/test/sso.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { parseCallbackParams } from "../src/sso" + +describe("parseCallbackParams", () => { + test("parses a full callback URL", () => { + const params = parseCallbackParams("http://127.0.0.1:5173/callback?code=abc&state=xyz") + expect(params?.get("code")).toBe("abc") + expect(params?.get("state")).toBe("xyz") + }) + + test("parses a scheme-less host/path paste", () => { + const params = parseCallbackParams("127.0.0.1:5173/callback?code=abc&state=xyz") + expect(params?.get("code")).toBe("abc") + }) + + test("parses a bare query string with and without the leading ?", () => { + expect(parseCallbackParams("?code=abc")?.get("code")).toBe("abc") + expect(parseCallbackParams("code=abc&state=s")?.get("state")).toBe("s") + expect(parseCallbackParams("error=denied")?.get("error")).toBe("denied") + }) + + test("returns null for a bare authorization code", () => { + expect(parseCallbackParams("some-opaque-code")).toBeNull() + }) +}) diff --git a/packages/codev-gateway/tsconfig.json b/packages/codev-gateway/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/codev-gateway/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/opencode/src/cli/cmd/configure.ts b/packages/opencode/src/cli/cmd/configure.ts new file mode 100644 index 0000000000..950fa12c1f --- /dev/null +++ b/packages/opencode/src/cli/cmd/configure.ts @@ -0,0 +1,19 @@ +import { cmd } from "./cmd" +import { TuiThreadCommand } from "./tui" + +// Fork-only: `codev configure` opens the TUI with the CoDev gateway configure +// dialog (SSO or own API key) already on screen — the standalone equivalent of +// `codevhub config`. The env flag is read by the TUI's app mount +// (packages/tui/src/app.tsx); everything else is the normal TUI startup, so +// finishing (or dismissing) the dialog lands in the regular chat. +export const ConfigureCommand = cmd({ + command: "configure", + describe: "configure the CoDev AI gateway (SSO or API key)", + handler: async () => { + process.env.CODEV_CONFIGURE = "1" + await TuiThreadCommand.handler({ + _: [], + $0: "codev", + } as unknown as Parameters[0]) + }, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 29ba3136f0..7e8c170edf 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -21,6 +21,7 @@ import { ImportCommand } from "./cli/cmd/import" import { AttachCommand } from "./cli/cmd/attach" import { TuiThreadCommand } from "./cli/cmd/tui" import { AcpCommand } from "./cli/cmd/acp" +import { ConfigureCommand } from "./cli/cmd/configure" import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" @@ -81,6 +82,7 @@ const cli = yargs(args) .command(AcpCommand) .command(McpCommand) .command(TuiThreadCommand) + .command(ConfigureCommand) .command(AttachCommand) .command(RunCommand) .command(GenerateCommand) diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 6e1e3a769b..7875b0f20b 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -41,6 +41,19 @@ Options: --pure run without external plugins [boolean]" `; +exports[`codev CLI help-text snapshots every documented command emits stable help text: codev configure --help 1`] = ` +"codev configure + +configure the CoDev AI gateway (SSO or API key) + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean]" +`; + exports[`codev CLI help-text snapshots every documented command emits stable help text: codev attach --help 1`] = ` "codev attach diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index 390d90dd99..b846547b6a 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -45,6 +45,7 @@ function normalize(text: string): string { const TOP_LEVEL = [ "acp", "mcp", + "configure", "attach", "run", "debug", diff --git a/packages/tui/package.json b/packages/tui/package.json index cd853f05b5..d7107794f6 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -48,6 +48,7 @@ "./component/register-spinner": "./src/component/register-spinner.ts" }, "dependencies": { + "@codev/gateway": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -62,8 +63,8 @@ "open": "10.1.2", "opentui-spinner": "catalog:", "remeda": "catalog:", - "strip-ansi": "7.1.2", - "solid-js": "catalog:" + "solid-js": "catalog:", + "strip-ansi": "7.1.2" }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 515e046c6a..b3c21d17e7 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -27,6 +27,7 @@ import { import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" import { DialogProvider as DialogProviderList } from "./component/dialog-provider" +import { DialogConfigure } from "./component/dialog-configure" import { ErrorComponent } from "./component/error-component" import { PluginRouteMissing } from "./component/plugin-route-missing" import { ProjectProvider, useProject } from "./context/project" @@ -543,11 +544,19 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi (isEmpty, wasEmpty) => { // only trigger when we transition into an empty-provider state if (!isEmpty || wasEmpty) return - dialog.replace(() => ) + // Fork: first-run setup goes to the CoDev gateway configure flow; + // upstream's provider dialog stays reachable via /connect. + dialog.replace(() => ) }, ), ) + // Fork: `codev configure` re-opens the gateway configure dialog on demand. + onMount(() => { + if (process.env.CODEV_CONFIGURE !== "1") return + dialog.replace(() => ) + }) + const connected = useConnected() const currentWorktreeWorkspace = createMemo(() => { const workspaceID = project.workspace.current() @@ -745,6 +754,16 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }, category: "Provider", }, + // Fork: the CoDev gateway configure flow (SSO or own API key). + { + name: "codev.configure", + title: "Configure CoDev gateway", + slashName: "configure", + run: () => { + dialog.replace(() => ) + }, + category: "Provider", + }, ...(sync.data.console_state.switchableOrgCount > 1 ? [ { diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx new file mode 100644 index 0000000000..04cc127afa --- /dev/null +++ b/packages/tui/src/component/dialog-configure.tsx @@ -0,0 +1,216 @@ +import { + buildGatewayConfig, + FALLBACK_MODEL, + fetchApiKey, + fetchCodevConfig, + fetchModels, + isInvalidKeyError, + login, + saveApiKey, + saveCodevConfig, + silentSso, +} from "@codev/gateway" +import open from "open" +import { createSignal, onMount, Show } from "solid-js" +import { useSync } from "../context/sync" +import { useSDK } from "../context/sdk" +import { useTheme } from "../context/theme" +import { useDialog } from "../ui/dialog" +import { DialogPrompt } from "../ui/dialog-prompt" +import { DialogSelect } from "../ui/dialog-select" +import { Link } from "../ui/link" +import { useToast } from "../ui/toast" + +// Fork-only: the standalone configure flow for the CoDev AI gateway — the +// in-agent equivalent of `codevhub config`. Login with SSO (gateway key minted +// via the CoDev backend) or paste an existing key, pick a default model, then +// write the same opencode.json provider block the hub writes. The SSO session +// and key land in the hub-shared ~/.codev/auth.json. Upstream's /connect +// dialog remains the escape hatch for non-gateway providers. +export function DialogConfigure() { + const dialog = useDialog() + + return ( + dialog.replace(() => ), + }, + { + title: "I have my own API key", + value: "manual", + description: "Enter the gateway URL and API key", + async onSelect() { + const baseUrl = await DialogPrompt.show(dialog, "API URL", { + placeholder: "https://gateway.example.com", + }) + if (!baseUrl?.trim()) return + const apiKey = await DialogPrompt.show(dialog, "API Key", { placeholder: "sk-..." }) + if (!apiKey?.trim()) return + dialog.replace(() => ( + + )) + }, + }, + ]} + /> + ) +} + +function SsoStep() { + const dialog = useDialog() + const toast = useToast() + const { theme } = useTheme() + const [status, setStatus] = createSignal("Starting SSO login...") + const [authUrl, setAuthUrl] = createSignal() + const [error, setError] = createSignal() + // Set when login() hands back the interactive step; stays undefined on the + // silent-refresh path where no paste-back is ever needed. + let submitManualCode: ((pasted: string) => string | null) | undefined + + onMount(async () => { + try { + const session = + (await silentSso()) ?? + (await login({ + onLog: setStatus, + onReady(openBrowserFn, url, submit) { + submitManualCode = submit + setAuthUrl(url) + openBrowserFn() + }, + openUrl: open, + })) + + setStatus("Fetching gateway configuration...") + const config = await fetchCodevConfig(session.access_token) + saveCodevConfig(config) + + setStatus("Fetching gateway API key...") + const apiKey = await fetchApiKey(session.access_token) + // The backend returns an empty key for accounts without a provisioned + // gateway key — same as the hub, route those to manual entry. + if (!apiKey) { + toast.show({ + variant: "info", + message: "Your account has no gateway API key. Enter credentials manually.", + }) + const pasted = await DialogPrompt.show(dialog, "API Key", { placeholder: "sk-..." }) + if (!pasted?.trim()) return + dialog.replace(() => ( + + )) + return + } + + dialog.replace(() => ) + } catch (err) { + toast.error(err instanceof Error ? err : new Error(String(err))) + dialog.clear() + } + }) + + return ( + { + if (!submitManualCode) return + const problem = submitManualCode(value) + setError(problem ?? undefined) + }} + description={() => ( + + {status()} + + Complete the login in your browser, or open: + + + + {error()} + + + )} + /> + ) +} + +interface ModelStepProps { + apiKey: string + // Where the provider block's baseURL points (normalized to /v1 at write). + gatewayBaseUrl: string + // Written to auth.json's base_url — set on the manual path only; SSO-minted + // keys store no base_url, matching the hub (the gateway URL lives in the + // config block of auth.json instead). + authFileBaseUrl?: string +} + +function ModelStep(props: ModelStepProps) { + const dialog = useDialog() + const sdk = useSDK() + const sync = useSync() + const toast = useToast() + const { theme } = useTheme() + const [models, setModels] = createSignal() + + async function finalize(model: string, list?: string[]) { + try { + saveApiKey({ apiKey: props.apiKey, baseUrl: props.authFileBaseUrl, model }) + const result = await sdk.client.global.config.update({ + config: buildGatewayConfig({ + apiKey: props.apiKey, + baseUrl: props.gatewayBaseUrl, + model, + models: list, + }), + }) + if (result.error) throw new Error(JSON.stringify(result.error)) + await sync.bootstrap() + toast.show({ variant: "info", message: `CoDev Code configured — using ${model}` }) + dialog.clear() + } catch (err) { + toast.error(err instanceof Error ? err : new Error(String(err))) + dialog.clear() + } + } + + onMount(async () => { + try { + setModels(await fetchModels(props.apiKey, props.gatewayBaseUrl)) + } catch (err) { + if (isInvalidKeyError(err)) { + toast.show({ variant: "error", message: "The gateway rejected that API key. Check it and try again." }) + dialog.replace(() => ) + return + } + // Network/5xx/empty list — configure anyway with the known-good default + // so the user isn't stranded; they can switch models later. + toast.show({ variant: "info", message: `Couldn't list models — defaulting to ${FALLBACK_MODEL}` }) + await finalize(FALLBACK_MODEL) + } + }) + + return ( + + Fetching models... + + } + > + ({ + title: id, + value: id, + onSelect: () => void finalize(id, models()), + }))} + /> + + ) +} From 70ac44f214e05ee124b31bdcde15877630440949 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 10 Jul 2026 14:59:04 +0700 Subject: [PATCH 02/12] feat: replace the fallback model with retry / manual re-entry When the gateway's model list can't be fetched, silently configuring the fallback model just moves the failure to the first chat message. Show the error instead, with two options: Retry (3 attempts) and "Enter my own API key" (re-prompts URL + key). When the retry budget is spent, leave the flow with an actionable message. Drops the now- unused FALLBACK_MODEL and isInvalidKeyError from the gateway package. Co-Authored-By: Claude Fable 5 --- packages/codev-gateway/src/backend.ts | 8 -- packages/codev-gateway/src/const.ts | 2 - packages/codev-gateway/src/index.ts | 3 +- .../tui/src/component/dialog-configure.tsx | 108 +++++++++++++----- 4 files changed, 78 insertions(+), 43 deletions(-) diff --git a/packages/codev-gateway/src/backend.ts b/packages/codev-gateway/src/backend.ts index 4109305f72..a74598eebb 100644 --- a/packages/codev-gateway/src/backend.ts +++ b/packages/codev-gateway/src/backend.ts @@ -78,14 +78,6 @@ export async function fetchModels(apiKey: string, baseUrl: string): Promise ( - + )) }, }, @@ -61,6 +55,14 @@ export function DialogConfigure() { ) } +async function promptManualCredentials(dialog: ReturnType) { + const baseUrl = await DialogPrompt.show(dialog, "API URL", { placeholder: "https://gateway.example.com" }) + if (!baseUrl?.trim()) return null + const apiKey = await DialogPrompt.show(dialog, "API Key", { placeholder: "sk-..." }) + if (!apiKey?.trim()) return null + return { baseUrl: baseUrl.trim(), apiKey: apiKey.trim() } +} + function SsoStep() { const dialog = useDialog() const toast = useToast() @@ -149,6 +151,8 @@ interface ModelStepProps { authFileBaseUrl?: string } +const MODEL_FETCH_RETRIES = 3 + function ModelStep(props: ModelStepProps) { const dialog = useDialog() const sdk = useSDK() @@ -156,8 +160,10 @@ function ModelStep(props: ModelStepProps) { const toast = useToast() const { theme } = useTheme() const [models, setModels] = createSignal() + const [failure, setFailure] = createSignal() + let retries = 0 - async function finalize(model: string, list?: string[]) { + async function finalize(model: string, list: string[]) { try { saveApiKey({ apiKey: props.apiKey, baseUrl: props.authFileBaseUrl, model }) const result = await sdk.client.global.config.update({ @@ -178,39 +184,79 @@ function ModelStep(props: ModelStepProps) { } } - onMount(async () => { + async function loadModels() { + setFailure(undefined) try { setModels(await fetchModels(props.apiKey, props.gatewayBaseUrl)) } catch (err) { - if (isInvalidKeyError(err)) { - toast.show({ variant: "error", message: "The gateway rejected that API key. Check it and try again." }) - dialog.replace(() => ) + const message = err instanceof Error ? err.message : String(err) + // No fallback model: a config pointing at an unreachable gateway or a + // bad key would just fail on the first chat. After the retry budget is + // spent, leave the flow with an actionable message. + if (retries >= MODEL_FETCH_RETRIES) { + toast.show({ + variant: "error", + message: `Couldn't fetch models after ${MODEL_FETCH_RETRIES} retries: ${message}. Check the gateway and run /configure to try again.`, + }) + dialog.clear() return } - // Network/5xx/empty list — configure anyway with the known-good default - // so the user isn't stranded; they can switch models later. - toast.show({ variant: "info", message: `Couldn't list models — defaulting to ${FALLBACK_MODEL}` }) - await finalize(FALLBACK_MODEL) + toast.show({ variant: "error", message }) + setFailure(message) } - }) + } + + onMount(loadModels) return ( - Fetching models... - + { + retries += 1 + void loadModels() + }, + }, + { + title: "Enter my own API key", + value: "manual", + description: "Re-enter the gateway URL and API key", + async onSelect() { + const creds = await promptManualCredentials(dialog) + if (!creds) return + dialog.replace(() => ( + + )) + }, + }, + ]} + /> } > - ({ - title: id, - value: id, - onSelect: () => void finalize(id, models()), - }))} - /> + + Fetching models... + + } + > + ({ + title: id, + value: id, + onSelect: () => void finalize(id, models()!), + }))} + /> + ) } From 34a419be18af0b7f00722b596337449afc09d9a3 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 10 Jul 2026 15:10:33 +0700 Subject: [PATCH 03/12] fix(tui): fire the configure gate on fresh installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream's empty-provider gate never fires in practice: the hosted Zen free models register as the `opencode` provider, so provider.length is never 0 on a fresh install and no setup dialog appeared. Gate on useConnected() instead — false when only those free models exist — which is the same signal the home tip uses. Also point that tip at /configure instead of /connect. Verified by booting the dev TUI in a PTY with a fresh XDG_CONFIG_HOME: the Configure CoDev Code dialog opens on startup with both options. Co-Authored-By: Claude Fable 5 --- DIVERGENCES.md | 3 ++- packages/tui/src/app.tsx | 17 ++++++++++------- .../tui/src/feature-plugins/home/tips-view.tsx | 4 +++- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 439069ae39..ec6a665729 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -21,6 +21,7 @@ upstream maintainers — see README → Syncing with upstream for why and how. | `packages/opencode/package.json` | bin key `opencode` → `codev` (shim path `./bin/opencode` unchanged); added `release` script | Installed command is `codev` — matches the CLI's `scriptName` and help text (the hub's command is `codevhub`; it relinquished the `codev` bin in codev-ai ≥ 0.4.0). `bun run release` publishes the version in this file (upstream-synced; script/release.ts is fork-only). | Keep our bin key and release line; take upstream's other fields including `version`. | | `bun.lock` | Mirrors the bin key rename above and the fork-only workspace packages/deps (`@codev/gateway`) | Lockfile records workspace bin mappings and workspace deps. | Regenerate with `bun install`; never hand-merge. | | `packages/tui/package.json` | Added dependency `"@codev/gateway": "workspace:*"` | The fork's gateway configure dialog imports SSO/backend/config-block logic from the fork-only `packages/codev-gateway`. | Keep our dependency line; take upstream's other fields. | +| `packages/tui/src/feature-plugins/home/tips-view.tsx` | `NO_MODELS_TIP` points at `/configure` (CoDev gateway) instead of `/connect` | The unconfigured-state tip should lead to the fork's configure flow. | Keep our tip text. | | `packages/opencode/script/build.ts` | Platform packages' `package.json` name `codev-code-` (dist dir names unchanged — the bun compile target derives from them) | npm publishing under the fork's names. | Keep our `name:` line; take upstream's surrounding changes. | | `packages/opencode/script/publish.ts` | `PUBLISH_NAME = "codev-code"` const; npm description "CoDev Code — the AI agent harness"; published-name→dir map; main pkg name + bin key `codev`; embedded README (hub via `codevhub install`, direct install runs `codev`); env-guarded exit before the Docker/AUR/Homebrew section | npm-only publishing under the fork's names (unscoped, public on npmjs); the installed command is `codev`; upstream's other channels target anomalyco infra. | Keep our names, description, bin key + early exit; take upstream's npm-section changes above the exit. | | `packages/opencode/bin/opencode` | `base` prefix `opencode-` → `codev-code-` | Runtime platform-binary lookup must match the published names. | Keep our prefix. | @@ -31,7 +32,7 @@ upstream maintainers — see README → Syncing with upstream for why and how. | `packages/opencode/src/cli/ui.ts` | `wordmark`/`logo()` render the hub-style CODEV banner (bold bright cyan on TTY) | Help banner matches the hub CLI banner. | Keep our wordmark + logo(); take upstream's surrounding changes. | | `packages/tui/src/util/presentation.ts` | Local logo copy replaced by the shared CODEV `banner` (cyan) | Presentation-mode wordmark was still upstream-branded. | Keep our wordmark(); take upstream's surrounding changes. | | `packages/opencode/src/cli/cmd/*.ts` (tui, run, serve, attach, pr, upgrade, uninstall, web) | Help `describe` strings "opencode" → "codev" | `--help` is the first thing users read. | Keep our strings. | -| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; empty-provider startup dialog swapped from `DialogProviderList` to the fork's `DialogConfigure` (component/dialog-configure.tsx, new file); added the `codev.configure` command (`/configure`) and a `CODEV_CONFIGURE=1` mount hook for `codev configure` | Tab title branding; first-run setup routes to the CoDev gateway configure flow (upstream's `/connect` stays as the escape hatch). | Keep our strings, dialog swap + command block; take upstream's surrounding changes. | +| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; startup dialog effect gates on `!useConnected()` (not `provider.length === 0` — hosted Zen free models always register) and opens the fork's `DialogConfigure` (component/dialog-configure.tsx, new file); added the `codev.configure` command (`/configure`) and a `CODEV_CONFIGURE=1` mount hook for `codev configure` | Tab title branding; first-run setup routes to the CoDev gateway configure flow (upstream's `/connect` stays as the escape hatch). | Keep our strings, gate condition, dialog swap + command block; take upstream's surrounding changes. | | `packages/tui/src/routes/session/permission.tsx`, `packages/opencode/src/cli/cmd/run/permission.shared.ts`, `packages/opencode/test/cli/run/permission.shared.test.ts`, `packages/tui/src/component/error-component.tsx`, `packages/opencode/src/cli/cmd/run/splash.ts` | Visible sentences/badges "OpenCode"/"opencode" → "CoDev Code"/"codev-code" (incl. the permission always-allow copy and its test expectations) | User-facing copy. | Keep our strings. | | `packages/opencode/src/installation/index.ts` | Updater detects/queries/installs `codev-code` (npm-family); GitHub fallback → quickbeard/codev-code | `codev-code upgrade` and the update notification target OUR npm package, never stock opencode. brew/choco/scoop probes never match (no such packages). | Keep our package names. | | `packages/opencode/src/config/config.ts` | `share` defaults to `"disabled"` when unset | Sharing publishes sessions to upstream's share service; internal users must opt in via config. | Keep our default block. | diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b3c21d17e7..b483656ef2 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -538,14 +538,18 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi }) }) + // Fork: upstream gates on a literally empty provider list, which a fresh + // install never has — the hosted Zen free models register as the `opencode` + // provider. Gate on useConnected() instead (false when only those free + // models exist), so first-run setup goes to the CoDev gateway configure + // flow; upstream's provider dialog stays reachable via /connect. + const connected = useConnected() 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 - // Fork: first-run setup goes to the CoDev gateway configure flow; - // upstream's provider dialog stays reachable via /connect. + () => sync.status === "complete" && !connected(), + (unconfigured, wasUnconfigured) => { + // only trigger when we transition into an unconfigured state + if (!unconfigured || wasUnconfigured) return dialog.replace(() => ) }, ), @@ -557,7 +561,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi dialog.replace(() => ) }) - const connected = useConnected() const currentWorktreeWorkspace = createMemo(() => { const workspaceID = project.workspace.current() if (!workspaceID) return diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx index 32dd86f7a7..2276deece6 100644 --- a/packages/tui/src/feature-plugins/home/tips-view.tsx +++ b/packages/tui/src/feature-plugins/home/tips-view.tsx @@ -68,7 +68,9 @@ function parse(tip: string): TipPart[] { return parts } -const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding" +// Fork: point first-run users at the CoDev gateway configure flow; /connect +// (upstream's provider dialog) remains available for other providers. +const NO_MODELS_TIP = "Run {highlight}/configure{/highlight} to set up the CoDev gateway and start coding" const NO_MODELS_PARTS = parse(NO_MODELS_TIP) function shortcutText(value: string) { From b6eec9aa64b63d5ceade9d48c4f62b3dae93e4f1 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 10 Jul 2026 15:28:38 +0700 Subject: [PATCH 04/12] fix(tui): hoist configure flow state out of dialog components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dialog.replace stores the element factory and solid can re-invoke it on unrelated re-renders — a toast is enough. ModelStep kept its fetch state in component signals, so the error toast from a failed model fetch remounted a fresh instance that refetched for another 10s, whose failure toasted again: an invisible remount loop that rendered as a permanent "Fetching models...". Restructure to awaited controller functions (runSsoFlow/runModelFlow) that own all flow state; the dialog factories now produce pure views over controller signals, so a remount is invisible. Verified by driving the real TUI with expect: against a never-responding gateway the retry select appears after the 10s timeout, and against a working stub the manual path completes end-to-end (model select, configured toast, correct opencode.jsonc and auth.json). Co-Authored-By: Claude Fable 5 --- .../tui/src/component/dialog-configure.tsx | 332 ++++++++++-------- 1 file changed, 179 insertions(+), 153 deletions(-) diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index 954c663db9..d20886460d 100644 --- a/packages/tui/src/component/dialog-configure.tsx +++ b/packages/tui/src/component/dialog-configure.tsx @@ -9,13 +9,13 @@ import { silentSso, } from "@codev/gateway" import open from "open" -import { createSignal, onMount, Show } from "solid-js" +import { createSignal, Show, type Accessor } from "solid-js" import { useSync } from "../context/sync" import { useSDK } from "../context/sdk" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { DialogPrompt } from "../ui/dialog-prompt" -import { DialogSelect } from "../ui/dialog-select" +import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" import { Link } from "../ui/link" import { useToast } from "../ui/toast" @@ -25,8 +25,28 @@ import { useToast } from "../ui/toast" // write the same opencode.json provider block the hub writes. The SSO session // and key land in the hub-shared ~/.codev/auth.json. Upstream's /connect // dialog remains the escape hatch for non-gateway providers. +// +// Architecture note: dialog.replace stores the element FACTORY, and solid can +// re-invoke it on unrelated re-renders (a toast is enough) — remounting the +// component with fresh state. So all flow state lives in the awaited +// controller functions below (runSsoFlow / runModelFlow); the components the +// factories produce are pure views over controller-owned signals, and a +// remount is invisible. + +interface FlowContext { + dialog: ReturnType + sdk: ReturnType + sync: ReturnType + toast: ReturnType +} + export function DialogConfigure() { - const dialog = useDialog() + const ctx: FlowContext = { + dialog: useDialog(), + sdk: useSDK(), + sync: useSync(), + toast: useToast(), + } return ( dialog.replace(() => ), + onSelect: () => void runSsoFlow(ctx), }, { title: "I have my own API key", value: "manual", description: "Enter the gateway URL and API key", async onSelect() { - const creds = await promptManualCredentials(dialog) + const creds = await promptManualCredentials(ctx.dialog) if (!creds) return - dialog.replace(() => ( - - )) + await runModelFlow(ctx, { apiKey: creds.apiKey, gatewayBaseUrl: creds.baseUrl, authFileBaseUrl: creds.baseUrl }) }, }, ]} @@ -55,7 +73,7 @@ export function DialogConfigure() { ) } -async function promptManualCredentials(dialog: ReturnType) { +async function promptManualCredentials(dialog: FlowContext["dialog"]) { const baseUrl = await DialogPrompt.show(dialog, "API URL", { placeholder: "https://gateway.example.com" }) if (!baseUrl?.trim()) return null const apiKey = await DialogPrompt.show(dialog, "API Key", { placeholder: "sk-..." }) @@ -63,77 +81,119 @@ async function promptManualCredentials(dialog: ReturnType) { return { baseUrl: baseUrl.trim(), apiKey: apiKey.trim() } } -function SsoStep() { - const dialog = useDialog() - const toast = useToast() +// Renders a select and resolves with the chosen value, or null when the user +// dismisses the dialog. The select stays on screen until the caller replaces +// or clears the dialog. +function showSelect( + dialog: FlowContext["dialog"], + title: string, + options: (Omit, "onSelect"> & { value: T })[], +): Promise { + return new Promise((resolve) => { + dialog.replace( + () => ( + ({ ...option, onSelect: () => resolve(option.value) }))} + /> + ), + () => resolve(null), + ) + }) +} + +function StatusBox(props: { text: string }) { const { theme } = useTheme() + return ( + + {props.text} + + ) +} + +async function runSsoFlow(ctx: FlowContext) { const [status, setStatus] = createSignal("Starting SSO login...") const [authUrl, setAuthUrl] = createSignal() - const [error, setError] = createSignal() - // Set when login() hands back the interactive step; stays undefined on the - // silent-refresh path where no paste-back is ever needed. + const [pasteError, setPasteError] = createSignal() let submitManualCode: ((pasted: string) => string | null) | undefined - onMount(async () => { - try { - const session = - (await silentSso()) ?? - (await login({ - onLog: setStatus, - onReady(openBrowserFn, url, submit) { - submitManualCode = submit - setAuthUrl(url) - openBrowserFn() - }, - openUrl: open, - })) + ctx.dialog.replace(() => ( + { + if (!submitManualCode) return + setPasteError(submitManualCode(value) ?? undefined) + }} + /> + )) - setStatus("Fetching gateway configuration...") - const config = await fetchCodevConfig(session.access_token) - saveCodevConfig(config) + try { + const session = + (await silentSso()) ?? + (await login({ + onLog: setStatus, + onReady(openBrowserFn, url, submit) { + submitManualCode = submit + setAuthUrl(url) + openBrowserFn() + }, + openUrl: open, + })) - setStatus("Fetching gateway API key...") - const apiKey = await fetchApiKey(session.access_token) - // The backend returns an empty key for accounts without a provisioned - // gateway key — same as the hub, route those to manual entry. - if (!apiKey) { - toast.show({ - variant: "info", - message: "Your account has no gateway API key. Enter credentials manually.", - }) - const pasted = await DialogPrompt.show(dialog, "API Key", { placeholder: "sk-..." }) - if (!pasted?.trim()) return - dialog.replace(() => ( - - )) - return - } + setStatus("Fetching gateway configuration...") + const config = await fetchCodevConfig(session.access_token) + saveCodevConfig(config) - dialog.replace(() => ) - } catch (err) { - toast.error(err instanceof Error ? err : new Error(String(err))) - dialog.clear() + setStatus("Fetching gateway API key...") + const apiKey = await fetchApiKey(session.access_token) + // The backend returns an empty key for accounts without a provisioned + // gateway key — same as the hub, route those to manual entry. + if (!apiKey) { + ctx.toast.show({ + variant: "info", + message: "Your account has no gateway API key. Enter it manually.", + }) + const pasted = await DialogPrompt.show(ctx.dialog, "API Key", { placeholder: "sk-..." }) + if (!pasted?.trim()) return + await runModelFlow(ctx, { + apiKey: pasted.trim(), + gatewayBaseUrl: config.gatewayUrl, + authFileBaseUrl: config.gatewayUrl, + }) + return } - }) + await runModelFlow(ctx, { apiKey, gatewayBaseUrl: config.gatewayUrl }) + } catch (err) { + ctx.toast.error(err instanceof Error ? err : new Error(String(err))) + ctx.dialog.clear() + } +} + +// Pure view over the SSO controller's signals; safe to remount at any time. +function SsoView(props: { + status: Accessor + authUrl: Accessor + pasteError: Accessor + onPaste: (value: string) => void +}) { + const { theme } = useTheme() return ( { - if (!submitManualCode) return - const problem = submitManualCode(value) - setError(problem ?? undefined) - }} + onConfirm={props.onPaste} description={() => ( - {status()} - + {props.status()} + Complete the login in your browser, or open: - + - - {error()} + + {props.pasteError()} )} @@ -141,7 +201,7 @@ function SsoStep() { ) } -interface ModelStepProps { +interface GatewayCreds { apiKey: string // Where the provider block's baseURL points (normalized to /v1 at write). gatewayBaseUrl: string @@ -153,110 +213,76 @@ interface ModelStepProps { const MODEL_FETCH_RETRIES = 3 -function ModelStep(props: ModelStepProps) { - const dialog = useDialog() - const sdk = useSDK() - const sync = useSync() - const toast = useToast() - const { theme } = useTheme() - const [models, setModels] = createSignal() - const [failure, setFailure] = createSignal() +async function runModelFlow(ctx: FlowContext, initial: GatewayCreds) { + let creds = initial let retries = 0 - - async function finalize(model: string, list: string[]) { + while (true) { + ctx.dialog.replace(() => ) + let models: string[] try { - saveApiKey({ apiKey: props.apiKey, baseUrl: props.authFileBaseUrl, model }) - const result = await sdk.client.global.config.update({ - config: buildGatewayConfig({ - apiKey: props.apiKey, - baseUrl: props.gatewayBaseUrl, - model, - models: list, - }), - }) - if (result.error) throw new Error(JSON.stringify(result.error)) - await sync.bootstrap() - toast.show({ variant: "info", message: `CoDev Code configured — using ${model}` }) - dialog.clear() - } catch (err) { - toast.error(err instanceof Error ? err : new Error(String(err))) - dialog.clear() - } - } - - async function loadModels() { - setFailure(undefined) - try { - setModels(await fetchModels(props.apiKey, props.gatewayBaseUrl)) + models = await fetchModels(creds.apiKey, creds.gatewayBaseUrl) } catch (err) { const message = err instanceof Error ? err.message : String(err) // No fallback model: a config pointing at an unreachable gateway or a // bad key would just fail on the first chat. After the retry budget is // spent, leave the flow with an actionable message. if (retries >= MODEL_FETCH_RETRIES) { - toast.show({ + ctx.toast.show({ variant: "error", message: `Couldn't fetch models after ${MODEL_FETCH_RETRIES} retries: ${message}. Check the gateway and run /configure to try again.`, }) - dialog.clear() + ctx.dialog.clear() return } - toast.show({ variant: "error", message }) - setFailure(message) + const choice = await showSelect(ctx.dialog, "Couldn't fetch models from the gateway", [ + { + title: "Retry", + value: "retry" as const, + description: `${message} — ${MODEL_FETCH_RETRIES - retries} attempt(s) left`, + }, + { + title: "Enter my own API key", + value: "manual" as const, + description: "Re-enter the gateway URL and API key", + }, + ]) + if (choice === null) return + if (choice === "retry") { + retries += 1 + continue + } + const next = await promptManualCredentials(ctx.dialog) + if (!next) return + creds = { apiKey: next.apiKey, gatewayBaseUrl: next.baseUrl, authFileBaseUrl: next.baseUrl } + retries = 0 + continue } - } - onMount(loadModels) + const model = await showSelect( + ctx.dialog, + "Choose default model", + models.map((id) => ({ title: id, value: id })), + ) + if (model === null) return - return ( - { - retries += 1 - void loadModels() - }, - }, - { - title: "Enter my own API key", - value: "manual", - description: "Re-enter the gateway URL and API key", - async onSelect() { - const creds = await promptManualCredentials(dialog) - if (!creds) return - dialog.replace(() => ( - - )) - }, - }, - ]} - /> - } - > - - Fetching models... - - } - > - ({ - title: id, - value: id, - onSelect: () => void finalize(id, models()!), - }))} - /> - - - ) + try { + saveApiKey({ apiKey: creds.apiKey, baseUrl: creds.authFileBaseUrl, model }) + const result = await ctx.sdk.client.global.config.update({ + config: buildGatewayConfig({ + apiKey: creds.apiKey, + baseUrl: creds.gatewayBaseUrl, + model, + models, + }), + }) + if (result.error) throw new Error(JSON.stringify(result.error)) + await ctx.sync.bootstrap() + ctx.toast.show({ variant: "info", message: `CoDev Code configured — using ${model}` }) + ctx.dialog.clear() + } catch (err) { + ctx.toast.error(err instanceof Error ? err : new Error(String(err))) + ctx.dialog.clear() + } + return + } } From 035b840cb61da2251ea8a8689348616d54acc7f4 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 10 Jul 2026 15:43:13 +0700 Subject: [PATCH 05/12] minor changes --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1810dc53a9..5b4c7aa400 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CoDev Code -CoDev Code is a fork of [OpenCode](https://github.com/anomalyco/opencode) that tracks upstream while carrying a small set of organization-specific changes. Read [DIVERGENCES.md](DIVERGENCES.md) before editing any upstream file — it defines where changes are allowed to live. +CoDev Code is a fork of [OpenCode](https://github.com/anomalyco/opencode) that tracks upstream while carrying a set of specific changes. Read [DIVERGENCES.md](DIVERGENCES.md) before editing any upstream file — it defines where changes are allowed to live. Upstream's [AGENTS.md](AGENTS.md) and [CONTRIBUTING.md](CONTRIBUTING.md) conventions (commit style, code style, package layout) apply here unchanged. From 799e11e83ca01fed671160a2b0b22a0d0405b062 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 10 Jul 2026 15:57:03 +0700 Subject: [PATCH 06/12] feat(tui): make the configure gate non-dismissable, drop its search/descriptions The CoDev gateway entry chooser is a first-run setup gate, so it now locks: escape, ctrl+c, and click-outside no-op while it's shown. A new `persistent` flag on the dialog store drives this (default false, reset on every replace/clear so all other dialogs stay dismissable); the entry chooser arms it in onMount and DialogSelect gained a `persistent` prop that hides the "esc" hint. The two-option chooser also drops its search bar (renderFilter={false}) and the per-option descriptions. Co-Authored-By: Claude Opus 4.8 (1M context) --- DIVERGENCES.md | 2 ++ .../tui/src/component/dialog-configure.tsx | 11 +++++--- packages/tui/src/ui/dialog-select.tsx | 11 +++++--- packages/tui/src/ui/dialog.tsx | 26 ++++++++++++++++++- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index ec6a665729..975349c7a1 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -22,6 +22,8 @@ upstream maintainers — see README → Syncing with upstream for why and how. | `bun.lock` | Mirrors the bin key rename above and the fork-only workspace packages/deps (`@codev/gateway`) | Lockfile records workspace bin mappings and workspace deps. | Regenerate with `bun install`; never hand-merge. | | `packages/tui/package.json` | Added dependency `"@codev/gateway": "workspace:*"` | The fork's gateway configure dialog imports SSO/backend/config-block logic from the fork-only `packages/codev-gateway`. | Keep our dependency line; take upstream's other fields. | | `packages/tui/src/feature-plugins/home/tips-view.tsx` | `NO_MODELS_TIP` points at `/configure` (CoDev gateway) instead of `/connect` | The unconfigured-state tip should lead to the fork's configure flow. | Keep our tip text. | +| `packages/tui/src/ui/dialog.tsx` | Added a `persistent` store flag (with `setPersistent`/`persistent` accessors) that no-ops the escape + ctrl+c bindings and the click-outside backdrop; every `replace`/`clear` resets it to false | The CoDev gateway configure gate is a non-dismissable first-run setup step; default-false keeps every other dialog dismissable. | Keep our persistent flag, the two cmd guards, the backdrop guard, and the reset lines; take upstream's surrounding changes. | +| `packages/tui/src/ui/dialog-select.tsx` | Added a `persistent?` prop that hides the top-right `esc` hint | A non-dismissable select shouldn't advertise `esc` (the dialog store's persistent flag blocks the actual close). | Keep our prop + the `Show` wrapper around the esc label; take upstream's surrounding changes. | | `packages/opencode/script/build.ts` | Platform packages' `package.json` name `codev-code-` (dist dir names unchanged — the bun compile target derives from them) | npm publishing under the fork's names. | Keep our `name:` line; take upstream's surrounding changes. | | `packages/opencode/script/publish.ts` | `PUBLISH_NAME = "codev-code"` const; npm description "CoDev Code — the AI agent harness"; published-name→dir map; main pkg name + bin key `codev`; embedded README (hub via `codevhub install`, direct install runs `codev`); env-guarded exit before the Docker/AUR/Homebrew section | npm-only publishing under the fork's names (unscoped, public on npmjs); the installed command is `codev`; upstream's other channels target anomalyco infra. | Keep our names, description, bin key + early exit; take upstream's npm-section changes above the exit. | | `packages/opencode/bin/opencode` | `base` prefix `opencode-` → `codev-code-` | Runtime platform-binary lookup must match the published names. | Keep our prefix. | diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index d20886460d..d6063758f7 100644 --- a/packages/tui/src/component/dialog-configure.tsx +++ b/packages/tui/src/component/dialog-configure.tsx @@ -9,7 +9,7 @@ import { silentSso, } from "@codev/gateway" import open from "open" -import { createSignal, Show, type Accessor } from "solid-js" +import { createSignal, onMount, Show, type Accessor } from "solid-js" import { useSync } from "../context/sync" import { useSDK } from "../context/sdk" import { useTheme } from "../context/theme" @@ -48,20 +48,25 @@ export function DialogConfigure() { toast: useToast(), } + // The entry chooser is a non-dismissable setup gate: no escape, no + // click-outside. The next replace() (a flow step) resets the flag, so only + // this first screen is locked. Re-arm on mount so a remount stays locked. + onMount(() => ctx.dialog.setPersistent(true)) + return ( void runSsoFlow(ctx), }, { title: "I have my own API key", value: "manual", - description: "Enter the gateway URL and API key", async onSelect() { const creds = await promptManualCredentials(ctx.dialog) if (!creds) return diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index dab9103d24..e99706a4a2 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -34,6 +34,9 @@ export interface DialogSelectProps { onSelect?: (option: DialogSelectOption) => void skipFilter?: boolean renderFilter?: boolean + // Fork: hide the "esc" hint — set on non-dismissable dialogs (the dialog + // store's persistent flag blocks the actual escape/backdrop close). + persistent?: boolean locked?: boolean preserveSelection?: boolean actions?: { @@ -563,9 +566,11 @@ export function DialogSelect(props: DialogSelectProps) { {props.title} )} - dialog.clear()}> - esc - + + dialog.clear()}> + esc + + diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 50281630bc..7e458936e9 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -73,6 +73,11 @@ function init() { onClose?: () => void }[], size: "medium" as "medium" | "large" | "xlarge", + // Fork: a persistent dialog can't be dismissed — escape, ctrl+c, and the + // click-outside backdrop all no-op. Reset to false on every replace/clear + // so dialogs are dismissable by default; a dialog opts in via + // setPersistent(true) (the CoDev gateway configure gate uses this). + persistent: false, }) const renderer = useRenderer() @@ -110,6 +115,7 @@ function init() { desc: "Close dialog", group: "Dialog", cmd: () => { + if (store.persistent) return if (renderer.getSelection()) { renderer.clearSelection() } @@ -124,6 +130,7 @@ function init() { desc: "Close dialog", group: "Dialog", cmd: () => { + if (store.persistent) return if (renderer.getSelection()) { renderer.clearSelection() } @@ -143,6 +150,7 @@ function init() { } batch(() => { setStore("size", "medium") + setStore("persistent", false) setStore("stack", []) }) refocus() @@ -156,6 +164,9 @@ function init() { if (item.onClose) item.onClose() } setStore("size", "medium") + // Fork: a new dialog is dismissable until it opts back into persistence; + // the configure gate re-arms it in DialogConfigure's onMount. + setStore("persistent", false) setStore("stack", [ { element: input, @@ -172,6 +183,12 @@ function init() { setSize(size: "medium" | "large" | "xlarge") { setStore("size", size) }, + get persistent() { + return store.persistent + }, + setPersistent(value: boolean) { + setStore("persistent", value) + }, } } @@ -213,7 +230,14 @@ export function DialogProvider(props: ParentProps) { onMouseUp={!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? copySelection : undefined} > - value.clear()} size={value.size}> + { + // Fork: click-outside can't dismiss a persistent dialog. + if (value.persistent) return + value.clear() + }} + size={value.size} + > {value.stack.at(-1)!.element} From 4f1e79ce1cb9665659ed5fc4bdc3dd32127ebe35 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 10 Jul 2026 16:21:54 +0700 Subject: [PATCH 07/12] feat(tui): hide esc across the configure flow, ctrl+c quits, press-c copies the SSO link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the configure gate: the login prompt, model/retry selects, and manual-credential prompts are all persistent now, so none show the "esc" hint and none can be escaped or clicked away — the flow is one forced setup. ctrl+c stops being swallowed by a persistent dialog and instead dispatches app.exit (the global binding is base-mode only and an open dialog is modal), so it's the quit escape hatch throughout. DialogPrompt gains persistent/onInput/ref props; SsoView watches the input for a lone "c" and copies the sign-in URL to the clipboard (clearing the field so the callback can then be pasted), mirroring the codev hub's Login. A "(press c to copy the link)" hint sits under the link and flips to "Copied to clipboard" on success. Co-Authored-By: Claude Opus 4.8 (1M context) --- DIVERGENCES.md | 3 +- .../tui/src/component/dialog-configure.tsx | 49 ++++++++++++++++--- packages/tui/src/ui/dialog-prompt.tsx | 20 ++++++-- packages/tui/src/ui/dialog.tsx | 12 ++++- 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 975349c7a1..33e1b4b4a1 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -22,8 +22,9 @@ upstream maintainers — see README → Syncing with upstream for why and how. | `bun.lock` | Mirrors the bin key rename above and the fork-only workspace packages/deps (`@codev/gateway`) | Lockfile records workspace bin mappings and workspace deps. | Regenerate with `bun install`; never hand-merge. | | `packages/tui/package.json` | Added dependency `"@codev/gateway": "workspace:*"` | The fork's gateway configure dialog imports SSO/backend/config-block logic from the fork-only `packages/codev-gateway`. | Keep our dependency line; take upstream's other fields. | | `packages/tui/src/feature-plugins/home/tips-view.tsx` | `NO_MODELS_TIP` points at `/configure` (CoDev gateway) instead of `/connect` | The unconfigured-state tip should lead to the fork's configure flow. | Keep our tip text. | -| `packages/tui/src/ui/dialog.tsx` | Added a `persistent` store flag (with `setPersistent`/`persistent` accessors) that no-ops the escape + ctrl+c bindings and the click-outside backdrop; every `replace`/`clear` resets it to false | The CoDev gateway configure gate is a non-dismissable first-run setup step; default-false keeps every other dialog dismissable. | Keep our persistent flag, the two cmd guards, the backdrop guard, and the reset lines; take upstream's surrounding changes. | +| `packages/tui/src/ui/dialog.tsx` | Added a `persistent` store flag (with `setPersistent`/`persistent` accessors): escape and the click-outside backdrop no-op while set, and ctrl+c dispatches `app.exit` (base-mode-only, so suppressed under modal) instead of closing; every `replace`/`clear` resets it to false | The CoDev gateway configure flow is a non-dismissable first-run gate; ctrl+c stays the quit escape hatch. Default-false keeps every other dialog dismissable. | Keep our persistent flag, the escape/backdrop guards, the ctrl+c app.exit dispatch, and the reset lines; take upstream's surrounding changes. | | `packages/tui/src/ui/dialog-select.tsx` | Added a `persistent?` prop that hides the top-right `esc` hint | A non-dismissable select shouldn't advertise `esc` (the dialog store's persistent flag blocks the actual close). | Keep our prop + the `Show` wrapper around the esc label; take upstream's surrounding changes. | +| `packages/tui/src/ui/dialog-prompt.tsx` | Added `persistent?` (hides the `esc` hint + `DialogPrompt.show` re-arms the store flag), `onInput?` (fires on textarea content change), and `ref?` props | The configure flow's prompts (SSO login, manual credentials) are non-dismissable, and the SSO view watches `onInput` for the lone-`c` copy shortcut on the paste-back link (needs the `ref` to clear the field). | Keep our three props, the `Show` around the esc label, the textarea `onContentChange`, and the `.show` re-arm; take upstream's surrounding changes. | | `packages/opencode/script/build.ts` | Platform packages' `package.json` name `codev-code-` (dist dir names unchanged — the bun compile target derives from them) | npm publishing under the fork's names. | Keep our `name:` line; take upstream's surrounding changes. | | `packages/opencode/script/publish.ts` | `PUBLISH_NAME = "codev-code"` const; npm description "CoDev Code — the AI agent harness"; published-name→dir map; main pkg name + bin key `codev`; embedded README (hub via `codevhub install`, direct install runs `codev`); env-guarded exit before the Docker/AUR/Homebrew section | npm-only publishing under the fork's names (unscoped, public on npmjs); the installed command is `codev`; upstream's other channels target anomalyco infra. | Keep our names, description, bin key + early exit; take upstream's npm-section changes above the exit. | | `packages/opencode/bin/opencode` | `base` prefix `opencode-` → `codev-code-` | Runtime platform-binary lookup must match the published names. | Keep our prefix. | diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index d6063758f7..280c73c63f 100644 --- a/packages/tui/src/component/dialog-configure.tsx +++ b/packages/tui/src/component/dialog-configure.tsx @@ -8,8 +8,10 @@ import { saveCodevConfig, silentSso, } from "@codev/gateway" +import { TextareaRenderable } from "@opentui/core" import open from "open" import { createSignal, onMount, Show, type Accessor } from "solid-js" +import { useClipboard } from "../context/clipboard" import { useSync } from "../context/sync" import { useSDK } from "../context/sdk" import { useTheme } from "../context/theme" @@ -79,16 +81,20 @@ export function DialogConfigure() { } async function promptManualCredentials(dialog: FlowContext["dialog"]) { - const baseUrl = await DialogPrompt.show(dialog, "API URL", { placeholder: "https://gateway.example.com" }) + const baseUrl = await DialogPrompt.show(dialog, "API URL", { + placeholder: "https://gateway.example.com", + persistent: true, + }) if (!baseUrl?.trim()) return null - const apiKey = await DialogPrompt.show(dialog, "API Key", { placeholder: "sk-..." }) + const apiKey = await DialogPrompt.show(dialog, "API Key", { placeholder: "sk-...", persistent: true }) if (!apiKey?.trim()) return null return { baseUrl: baseUrl.trim(), apiKey: apiKey.trim() } } -// Renders a select and resolves with the chosen value, or null when the user -// dismisses the dialog. The select stays on screen until the caller replaces -// or clears the dialog. +// Renders a select and resolves with the chosen value. The whole configure +// flow is a non-dismissable gate, so the select is persistent (no esc hint, no +// escape/backdrop close); ctrl+c quits the app instead. resolve(null) only ever +// fires if a caller clears the dialog out from under it. function showSelect( dialog: FlowContext["dialog"], title: string, @@ -99,11 +105,14 @@ function showSelect( () => ( ({ ...option, onSelect: () => resolve(option.value) }))} /> ), () => resolve(null), ) + // replace() resets the store's persistent flag; re-arm it for this step. + dialog.setPersistent(true) }) } @@ -133,6 +142,8 @@ async function runSsoFlow(ctx: FlowContext) { }} /> )) + // replace() resets the store's persistent flag; keep the login step locked. + ctx.dialog.setPersistent(true) try { const session = @@ -160,7 +171,7 @@ async function runSsoFlow(ctx: FlowContext) { variant: "info", message: "Your account has no gateway API key. Enter it manually.", }) - const pasted = await DialogPrompt.show(ctx.dialog, "API Key", { placeholder: "sk-..." }) + const pasted = await DialogPrompt.show(ctx.dialog, "API Key", { placeholder: "sk-...", persistent: true }) if (!pasted?.trim()) return await runModelFlow(ctx, { apiKey: pasted.trim(), @@ -185,17 +196,41 @@ function SsoView(props: { onPaste: (value: string) => void }) { const { theme } = useTheme() + const clipboard = useClipboard() + const [copied, setCopied] = createSignal(false) + let textarea: TextareaRenderable | undefined + + // Lone "c" is the copy shortcut for the sign-in URL (parity with the codev + // hub's Login): a pasted callback URL always arrives as one long string, so a + // field that settles on a bare "c" can only be the shortcut. Copy the URL and + // clear the field so the user can then paste the callback back. + const onInput = (value: string) => { + const url = props.authUrl() + if (value !== "c" || !url || !clipboard.write) return + void clipboard.write(url).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + textarea?.clear() + } + return ( (textarea = t)} description={() => ( {props.status()} Complete the login in your browser, or open: + + {copied() ? "Copied to clipboard" : "(press c to copy the link)"} + {props.pasteError()} @@ -223,6 +258,8 @@ async function runModelFlow(ctx: FlowContext, initial: GatewayCreds) { let retries = 0 while (true) { ctx.dialog.replace(() => ) + // replace() resets the store's persistent flag; keep this step locked. + ctx.dialog.setPersistent(true) let models: string[] try { models = await fetchModels(creds.apiKey, creds.gatewayBaseUrl) diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index f518fb2950..22341554cc 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -15,6 +15,13 @@ export type DialogPromptProps = { busyText?: string onConfirm?: (value: string) => void onCancel?: () => void + // Fork: hide the "esc" hint on non-dismissable prompts (the dialog store's + // persistent flag blocks the actual close). + persistent?: boolean + // Fork: fires on every content change; the configure flow watches it for the + // lone-"c" copy shortcut on the SSO paste-back link. + onInput?: (value: string) => void + ref?: (textarea: TextareaRenderable) => void } export function DialogPrompt(props: DialogPromptProps) { @@ -78,9 +85,11 @@ export function DialogPrompt(props: DialogPromptProps) { {props.title} - dialog.clear()}> - esc - + + dialog.clear()}> + esc + + {props.description?.()} @@ -89,7 +98,9 @@ export function DialogPrompt(props: DialogPromptProps) { ref={(val: TextareaRenderable) => { textarea = val setTextareaTarget(val) + props.ref?.(val) }} + onContentChange={() => props.onInput?.(textarea.plainText)} initialValue={props.value} placeholder={props.placeholder ?? "Enter text"} placeholderColor={theme.textMuted} @@ -122,5 +133,8 @@ DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit resolve(null), ) + // replace() resets the store's persistent flag; re-arm it for a + // non-dismissable prompt (the configure flow's forced steps). + if (options?.persistent) dialog.setPersistent(true) }) } diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 7e458936e9..6aa82f54f8 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -5,7 +5,7 @@ import { MouseButton, Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" import { useToast } from "./toast" import { Flag } from "@opencode-ai/core/flag/flag" -import { useBindings, useOpencodeModeStack } from "../keymap" +import { useBindings, useOpencodeModeStack, useOpencodeKeymap } from "../keymap" import { useClipboard } from "../context/clipboard" export function Dialog( @@ -82,6 +82,7 @@ function init() { const renderer = useRenderer() const modeStack = useOpencodeModeStack() + const keymap = useOpencodeKeymap() createEffect(() => { if (store.stack.length === 0) return @@ -130,7 +131,14 @@ function init() { desc: "Close dialog", group: "Dialog", cmd: () => { - if (store.persistent) return + // A persistent dialog is a forced gate (the CoDev configure flow) — + // it can't be dismissed, so ctrl+c is the escape hatch and quits the + // app instead. The global app_exit binding is base-mode only and the + // open dialog is in modal mode, so dispatch app.exit explicitly. + if (store.persistent) { + keymap.dispatchCommand("app.exit") + return + } if (renderer.getSelection()) { renderer.clearSelection() } From 0b49e95fd5ac38f4b14c52a8ff7f2e07b49d8a93 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 10 Jul 2026 16:33:16 +0700 Subject: [PATCH 08/12] fix(tui): lock the configure dialogs during async processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the SSO code is pasted-and-entered, and after a model is picked, the flow does async work (token exchange, gateway calls, config PATCH) while the same dialog stays on screen — the input stayed live, so it looked like Enter/select never applied. Lock both windows: the SSO prompt goes busy the moment a code is accepted (and again once login resolves, covering the browser path), so the field blurs and suspends; the model list is swapped for a "Configuring ..." status view before the config write. Both use the existing busy/StatusBox mechanisms. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tui/src/component/dialog-configure.tsx | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index 280c73c63f..840fb9540f 100644 --- a/packages/tui/src/component/dialog-configure.tsx +++ b/packages/tui/src/component/dialog-configure.tsx @@ -129,6 +129,10 @@ async function runSsoFlow(ctx: FlowContext) { const [status, setStatus] = createSignal("Starting SSO login...") const [authUrl, setAuthUrl] = createSignal() const [pasteError, setPasteError] = createSignal() + // Locks the paste field once a code is accepted (or the browser callback + // lands): the token exchange and every gateway call after it are async, so + // without this the input stays live and it looks like Enter did nothing. + const [busy, setBusy] = createSignal(false) let submitManualCode: ((pasted: string) => string | null) | undefined ctx.dialog.replace(() => ( @@ -136,9 +140,14 @@ async function runSsoFlow(ctx: FlowContext) { status={status} authUrl={authUrl} pasteError={pasteError} + busy={busy} onPaste={(value) => { - if (!submitManualCode) return - setPasteError(submitManualCode(value) ?? undefined) + if (busy() || !submitManualCode) return + const error = submitManualCode(value) + setPasteError(error ?? undefined) + // A null result means the code was accepted and login() is exchanging + // it now — lock the field so Enter visibly took effect. + if (!error) setBusy(true) }} /> )) @@ -158,6 +167,9 @@ async function runSsoFlow(ctx: FlowContext) { openUrl: open, })) + // Authenticated: everything below is non-interactive network work. Lock the + // view (covers the browser-callback path, which never touched the field). + setBusy(true) setStatus("Fetching gateway configuration...") const config = await fetchCodevConfig(session.access_token) saveCodevConfig(config) @@ -193,6 +205,7 @@ function SsoView(props: { status: Accessor authUrl: Accessor pasteError: Accessor + busy: Accessor onPaste: (value: string) => void }) { const { theme } = useTheme() @@ -218,6 +231,8 @@ function SsoView(props: { ) + ctx.dialog.setPersistent(true) + try { saveApiKey({ apiKey: creds.apiKey, baseUrl: creds.authFileBaseUrl, model }) const result = await ctx.sdk.client.global.config.update({ From a285e0913834a38ac42126b20ee650a9ad1bd450 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 10 Jul 2026 16:34:53 +0700 Subject: [PATCH 09/12] minor changes --- packages/tui/src/component/dialog-configure.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index 840fb9540f..edc152301c 100644 --- a/packages/tui/src/component/dialog-configure.tsx +++ b/packages/tui/src/component/dialog-configure.tsx @@ -72,7 +72,11 @@ export function DialogConfigure() { async onSelect() { const creds = await promptManualCredentials(ctx.dialog) if (!creds) return - await runModelFlow(ctx, { apiKey: creds.apiKey, gatewayBaseUrl: creds.baseUrl, authFileBaseUrl: creds.baseUrl }) + await runModelFlow(ctx, { + apiKey: creds.apiKey, + gatewayBaseUrl: creds.baseUrl, + authFileBaseUrl: creds.baseUrl, + }) }, }, ]} @@ -244,7 +248,7 @@ function SsoView(props: { Complete the login in your browser, or open: - {copied() ? "Copied to clipboard" : "(press c to copy the link)"} + {copied() ? "Copied to clipboard" : "(press C to copy the link)"} From 610dd86d3545c5825b5098062d8326935b0f1ec3 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 10 Jul 2026 16:43:26 +0700 Subject: [PATCH 10/12] fix(tui): retry configure network steps 3x, then quit instead of dropping to chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A timeout in the gateway steps hit runSsoFlow's catch, which cleared the dialog and dumped the user into an unconfigured chat — surprising for a forced setup gate. Now the retryable network calls (fetch config, fetch api key, apply config, and the existing model fetch) auto-retry up to 3 times with a "retrying (n/3)" status, and any unrecoverable failure quits the app with an epilogue ("CoDev Code setup failed: … Run `codev configure` to try again.") rather than falling through to chat. login() stays single-attempt — its auth code is single-use, so a silent retry can't reuse it — and quits on failure like the rest. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tui/src/component/dialog-configure.tsx | 86 +++++++++++++------ 1 file changed, 60 insertions(+), 26 deletions(-) diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index edc152301c..c10f1337f0 100644 --- a/packages/tui/src/component/dialog-configure.tsx +++ b/packages/tui/src/component/dialog-configure.tsx @@ -12,6 +12,8 @@ import { TextareaRenderable } from "@opentui/core" import open from "open" import { createSignal, onMount, Show, type Accessor } from "solid-js" import { useClipboard } from "../context/clipboard" +import { useEpilogue } from "../context/epilogue" +import { useExit } from "../context/exit" import { useSync } from "../context/sync" import { useSDK } from "../context/sdk" import { useTheme } from "../context/theme" @@ -40,6 +42,8 @@ interface FlowContext { sdk: ReturnType sync: ReturnType toast: ReturnType + exit: ReturnType + epilogue: ReturnType } export function DialogConfigure() { @@ -48,6 +52,8 @@ export function DialogConfigure() { sdk: useSDK(), sync: useSync(), toast: useToast(), + exit: useExit(), + epilogue: useEpilogue(), } // The entry chooser is a non-dismissable setup gate: no escape, no @@ -129,6 +135,30 @@ function StatusBox(props: { text: string }) { ) } +const RETRIES = 3 + +// Auto-retries a transient network step (timeouts, flaky gateway) up to RETRIES +// times, surfacing the countdown in the status line. Throws the last error once +// the budget is spent, so the caller can quit. +async function withRetries(label: string, onStatus: (text: string) => void, op: () => Promise): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await op() + } catch (err) { + if (attempt >= RETRIES) throw err + onStatus(`${label} failed — retrying (${attempt + 1}/${RETRIES})...`) + } + } +} + +// The configure flow is a forced setup gate, so an unrecoverable failure must +// not silently drop the user into an unconfigured chat. Print the reason as an +// exit epilogue (shown after the TUI tears down) and quit the app. +function failAndQuit(ctx: FlowContext, message: string) { + ctx.epilogue(`CoDev Code setup failed: ${message}\nRun \`codev configure\` to try again.`) + ctx.exit() +} + async function runSsoFlow(ctx: FlowContext) { const [status, setStatus] = createSignal("Starting SSO login...") const [authUrl, setAuthUrl] = createSignal() @@ -175,11 +205,13 @@ async function runSsoFlow(ctx: FlowContext) { // view (covers the browser-callback path, which never touched the field). setBusy(true) setStatus("Fetching gateway configuration...") - const config = await fetchCodevConfig(session.access_token) + const config = await withRetries("Fetching gateway configuration", setStatus, () => + fetchCodevConfig(session.access_token), + ) saveCodevConfig(config) setStatus("Fetching gateway API key...") - const apiKey = await fetchApiKey(session.access_token) + const apiKey = await withRetries("Fetching gateway API key", setStatus, () => fetchApiKey(session.access_token)) // The backend returns an empty key for accounts without a provisioned // gateway key — same as the hub, route those to manual entry. if (!apiKey) { @@ -199,8 +231,11 @@ async function runSsoFlow(ctx: FlowContext) { await runModelFlow(ctx, { apiKey, gatewayBaseUrl: config.gatewayUrl }) } catch (err) { - ctx.toast.error(err instanceof Error ? err : new Error(String(err))) - ctx.dialog.clear() + // login() is single-attempt (its auth code is single-use, so a silent + // retry can't reuse it); the retryable backend steps have already spent + // their budget by the time they throw here. Either way, quit rather than + // fall through to chat. + failAndQuit(ctx, err instanceof Error ? err.message : String(err)) } } @@ -270,8 +305,6 @@ interface GatewayCreds { authFileBaseUrl?: string } -const MODEL_FETCH_RETRIES = 3 - async function runModelFlow(ctx: FlowContext, initial: GatewayCreds) { let creds = initial let retries = 0 @@ -286,20 +319,16 @@ async function runModelFlow(ctx: FlowContext, initial: GatewayCreds) { const message = err instanceof Error ? err.message : String(err) // No fallback model: a config pointing at an unreachable gateway or a // bad key would just fail on the first chat. After the retry budget is - // spent, leave the flow with an actionable message. - if (retries >= MODEL_FETCH_RETRIES) { - ctx.toast.show({ - variant: "error", - message: `Couldn't fetch models after ${MODEL_FETCH_RETRIES} retries: ${message}. Check the gateway and run /configure to try again.`, - }) - ctx.dialog.clear() + // spent, quit rather than drop into an unconfigured chat. + if (retries >= RETRIES) { + failAndQuit(ctx, `couldn't fetch models after ${RETRIES} retries: ${message}`) return } const choice = await showSelect(ctx.dialog, "Couldn't fetch models from the gateway", [ { title: "Retry", value: "retry" as const, - description: `${message} — ${MODEL_FETCH_RETRIES - retries} attempt(s) left`, + description: `${message} — ${RETRIES - retries} attempt(s) left`, }, { title: "Enter my own API key", @@ -329,26 +358,31 @@ async function runModelFlow(ctx: FlowContext, initial: GatewayCreds) { // Swap the still-interactive list for a status view: writing auth.json and // PATCHing the global config is async, and a live list makes it look like // the selection didn't register. - ctx.dialog.replace(() => ) - ctx.dialog.setPersistent(true) + const setStatus = (text: string) => { + ctx.dialog.replace(() => ) + // replace() resets the store's persistent flag; keep this step locked. + ctx.dialog.setPersistent(true) + } + setStatus(`Configuring ${model}...`) try { saveApiKey({ apiKey: creds.apiKey, baseUrl: creds.authFileBaseUrl, model }) - const result = await ctx.sdk.client.global.config.update({ - config: buildGatewayConfig({ - apiKey: creds.apiKey, - baseUrl: creds.gatewayBaseUrl, - model, - models, - }), + await withRetries("Applying configuration", setStatus, async () => { + const result = await ctx.sdk.client.global.config.update({ + config: buildGatewayConfig({ + apiKey: creds.apiKey, + baseUrl: creds.gatewayBaseUrl, + model, + models, + }), + }) + if (result.error) throw new Error(JSON.stringify(result.error)) }) - if (result.error) throw new Error(JSON.stringify(result.error)) await ctx.sync.bootstrap() ctx.toast.show({ variant: "info", message: `CoDev Code configured — using ${model}` }) ctx.dialog.clear() } catch (err) { - ctx.toast.error(err instanceof Error ? err : new Error(String(err))) - ctx.dialog.clear() + failAndQuit(ctx, err instanceof Error ? err.message : String(err)) } return } From a612f7d33fd867e82d18f2185427669b1bd94579 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 10 Jul 2026 16:53:17 +0700 Subject: [PATCH 11/12] fix(tui): silently retry the model fetch, drop the manual re-entry prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model fetch showed an interactive Retry / "Enter my own API key" select on failure. Make it consistent with the other gateway steps: silently auto-retry 3 times (with a "retrying (n/3)" status), then quit with an epilogue. Drops the mid-flow "Enter my own API key" re-entry and collapses runModelFlow's retry loop into a linear fetch → pick → apply. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tui/src/component/dialog-configure.tsx | 120 +++++++----------- 1 file changed, 45 insertions(+), 75 deletions(-) diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index c10f1337f0..985f28c751 100644 --- a/packages/tui/src/component/dialog-configure.tsx +++ b/packages/tui/src/component/dialog-configure.tsx @@ -305,85 +305,55 @@ interface GatewayCreds { authFileBaseUrl?: string } -async function runModelFlow(ctx: FlowContext, initial: GatewayCreds) { - let creds = initial - let retries = 0 - while (true) { - ctx.dialog.replace(() => ) - // replace() resets the store's persistent flag; keep this step locked. +async function runModelFlow(ctx: FlowContext, creds: GatewayCreds) { + // Replaces the current step with a locked status view (replace() clears the + // persistent flag, so re-arm it). Used for every non-interactive step so the + // gate stays put and nothing looks clickable while work is in flight. + const setStatus = (text: string) => { + ctx.dialog.replace(() => ) ctx.dialog.setPersistent(true) - let models: string[] - try { - models = await fetchModels(creds.apiKey, creds.gatewayBaseUrl) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - // No fallback model: a config pointing at an unreachable gateway or a - // bad key would just fail on the first chat. After the retry budget is - // spent, quit rather than drop into an unconfigured chat. - if (retries >= RETRIES) { - failAndQuit(ctx, `couldn't fetch models after ${RETRIES} retries: ${message}`) - return - } - const choice = await showSelect(ctx.dialog, "Couldn't fetch models from the gateway", [ - { - title: "Retry", - value: "retry" as const, - description: `${message} — ${RETRIES - retries} attempt(s) left`, - }, - { - title: "Enter my own API key", - value: "manual" as const, - description: "Re-enter the gateway URL and API key", - }, - ]) - if (choice === null) return - if (choice === "retry") { - retries += 1 - continue - } - const next = await promptManualCredentials(ctx.dialog) - if (!next) return - creds = { apiKey: next.apiKey, gatewayBaseUrl: next.baseUrl, authFileBaseUrl: next.baseUrl } - retries = 0 - continue - } + } - const model = await showSelect( - ctx.dialog, - "Choose default model", - models.map((id) => ({ title: id, value: id })), - ) - if (model === null) return + setStatus("Fetching models...") + let models: string[] + try { + // No fallback model: a config pointing at an unreachable gateway or a bad + // key would just fail on the first chat. Silently retry, then quit rather + // than drop into an unconfigured chat. + models = await withRetries("Fetching models", setStatus, () => fetchModels(creds.apiKey, creds.gatewayBaseUrl)) + } catch (err) { + failAndQuit(ctx, `couldn't fetch models after ${RETRIES} retries: ${err instanceof Error ? err.message : String(err)}`) + return + } - // Swap the still-interactive list for a status view: writing auth.json and - // PATCHing the global config is async, and a live list makes it look like - // the selection didn't register. - const setStatus = (text: string) => { - ctx.dialog.replace(() => ) - // replace() resets the store's persistent flag; keep this step locked. - ctx.dialog.setPersistent(true) - } - setStatus(`Configuring ${model}...`) + const model = await showSelect( + ctx.dialog, + "Choose default model", + models.map((id) => ({ title: id, value: id })), + ) + if (model === null) return - try { - saveApiKey({ apiKey: creds.apiKey, baseUrl: creds.authFileBaseUrl, model }) - await withRetries("Applying configuration", setStatus, async () => { - const result = await ctx.sdk.client.global.config.update({ - config: buildGatewayConfig({ - apiKey: creds.apiKey, - baseUrl: creds.gatewayBaseUrl, - model, - models, - }), - }) - if (result.error) throw new Error(JSON.stringify(result.error)) + // Swap the still-interactive list for a status view: writing auth.json and + // PATCHing the global config is async, and a live list makes it look like the + // selection didn't register. + setStatus(`Configuring ${model}...`) + try { + saveApiKey({ apiKey: creds.apiKey, baseUrl: creds.authFileBaseUrl, model }) + await withRetries("Applying configuration", setStatus, async () => { + const result = await ctx.sdk.client.global.config.update({ + config: buildGatewayConfig({ + apiKey: creds.apiKey, + baseUrl: creds.gatewayBaseUrl, + model, + models, + }), }) - await ctx.sync.bootstrap() - ctx.toast.show({ variant: "info", message: `CoDev Code configured — using ${model}` }) - ctx.dialog.clear() - } catch (err) { - failAndQuit(ctx, err instanceof Error ? err.message : String(err)) - } - return + if (result.error) throw new Error(JSON.stringify(result.error)) + }) + await ctx.sync.bootstrap() + ctx.toast.show({ variant: "info", message: `CoDev Code configured — using ${model}` }) + ctx.dialog.clear() + } catch (err) { + failAndQuit(ctx, err instanceof Error ? err.message : String(err)) } } From 3f773e752e0a8b06ad463e533c3843f6918d66f5 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 10 Jul 2026 17:08:35 +0700 Subject: [PATCH 12/12] feat(tui): hide the hosted Zen model indicator in the prompt footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt footer advertised upstream's hosted Zen default (e.g. "Build · Big Pickle OpenCode Zen"), which is a detour for gateway users — the same `opencode`/`opencode-go` providers are already filtered from the provider and /model pickers. Gate the footer's model/provider indicator on a new `isHostedZenModel` memo so those providers show just the agent name; a configured gateway or real provider still shows its model. Co-Authored-By: Claude Opus 4.8 (1M context) --- DIVERGENCES.md | 1 + packages/tui/src/component/prompt/index.tsx | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 33e1b4b4a1..7a123876ff 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -40,6 +40,7 @@ upstream maintainers — see README → Syncing with upstream for why and how. | `packages/opencode/src/installation/index.ts` | Updater detects/queries/installs `codev-code` (npm-family); GitHub fallback → quickbeard/codev-code | `codev-code upgrade` and the update notification target OUR npm package, never stock opencode. brew/choco/scoop probes never match (no such packages). | Keep our package names. | | `packages/opencode/src/config/config.ts` | `share` defaults to `"disabled"` when unset | Sharing publishes sessions to upstream's share service; internal users must opt in via config. | Keep our default block. | | `packages/tui/src/component/dialog-provider.tsx`, `packages/tui/src/component/dialog-model.tsx`, `packages/opencode/src/cli/cmd/providers.ts` | Filter `opencode`/`opencode-go` out of provider pickers and the `/model` dialog (model list + Favorites/Recent; the model dialog's opencode-first sort became a plain name sort) | Upstream's hosted offerings (Zen/Go) are detours for gateway users. | Keep our filters. | +| `packages/tui/src/component/prompt/index.tsx` | Added an `isHostedZenModel` memo (current `providerID` is `opencode`/`opencode-go`) and gated the prompt footer's model/provider indicator on `!isHostedZenModel()` | Don't advertise the hosted Zen default (e.g. "Big Pickle · OpenCode Zen") in the prompt footer — same rationale as the provider/model filters. A configured gateway/real provider still shows its model. | Keep the memo + the extra `Show` condition; take upstream's surrounding changes. | | `packages/app/index.html` | `` → "CoDev Code" | Web UI tab title. | Keep our title. | | `packages/app/src/desktop-menu.ts`, `packages/app/src/components/windows-app-menu.tsx` | App-menu label "OpenCode" → "CoDev Code" | Visible menu branding. | Keep our labels. | | `packages/desktop/src/main/index.ts`, `packages/desktop/src/main/windows.ts` | APP_NAMES + window title → "CoDev Code" (Dev/Beta variants too) | Desktop app display name. | Keep our names. | diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 115c10296e..b153cddd1f 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -209,6 +209,14 @@ export function Prompt(props: PromptProps) { const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) + // Fork: hide the model/provider footer indicator for upstream's hosted Zen + // offerings (the `opencode`/`opencode-go` providers) — they're detours for + // gateway users, and the same ids are filtered from the provider/model + // pickers. A configured gateway/real provider still shows its model. + const isHostedZenModel = createMemo(() => { + const id = local.model.current()?.providerID + return id === "opencode" || id === "opencode-go" + }) const hasRightContent = createMemo(() => Boolean(props.right)) function promptModelWarning() { @@ -1447,7 +1455,7 @@ export function Prompt(props: PromptProps) { <Show when={store.mode === "normal" && local.permission.mode === "auto"}> <text fg={fadeColor(theme.textMuted, agentMetaAlpha())}>auto</text> </Show> - <Show when={store.mode === "normal"}> + <Show when={store.mode === "normal" && !isHostedZenModel()}> <box flexDirection="row" gap={1}> <text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text> <text