diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70fab59937..1560717a53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,13 @@ file is the human-readable summary of what changed and why.
### Session screen
+- **Scrollback-style layout** — the codev logo + tip render top-left as the
+ transcript's first entry and the prompt rides directly below the last message,
+ instead of a centered banner with a bottom-anchored input and a void between
+ them. Once the conversation fills the screen, the prompt pins to the bottom
+ and the transcript sticky-scrolls as before; paging up reaches the banner at
+ the top of the scrollback.
+ ([#55](https://github.com/quickbeard/codev-code/pull/55))
- **Start in a session, not the home screen** — running `codev` lands directly in
the session layout with a new empty session instead of the home screen. Empty
leftover sessions are reused (at most one per directory), `session.new`
@@ -17,13 +24,6 @@ file is the human-readable summary of what changed and why.
real conversation. Also fixed a startup "Unexpected server error" toast caused
by the route switch fetching the `"dummy"` placeholder session ID.
([#48](https://github.com/quickbeard/codev-code/pull/48))
-- **Scrollback-style layout** — the codev logo + tip render top-left as the
- transcript's first entry and the prompt rides directly below the last message,
- instead of a centered banner with a bottom-anchored input and a void between
- them. Once the conversation fills the screen, the prompt pins to the bottom
- and the transcript sticky-scrolls as before; paging up reaches the banner at
- the top of the scrollback.
- ([#55](https://github.com/quickbeard/codev-code/pull/55))
### Sidebar
diff --git a/packages/codev-gateway/test/backend.test.ts b/packages/codev-gateway/test/backend.test.ts
new file mode 100644
index 0000000000..fdd5fc870a
--- /dev/null
+++ b/packages/codev-gateway/test/backend.test.ts
@@ -0,0 +1,82 @@
+import { afterAll, describe, expect, test } from "bun:test"
+import { fetchModels, validateApiKey } from "../src/backend"
+
+// A real loopback server (not a mock) that records the path + auth header of the
+// last request, so tests assert the exact endpoint fetchModels/validateApiKey
+// build from a user-pasted base URL. Each test installs its own handler.
+let handler: (req: Request, url: URL) => Response = () => new Response("unset", { status: 500 })
+const seen: { path: string; auth: string | null }[] = []
+const server = Bun.serve({
+ port: 0,
+ fetch(req) {
+ const url = new URL(req.url)
+ seen.push({ path: url.pathname, auth: req.headers.get("authorization") })
+ return handler(req, url)
+ },
+})
+const base = `http://127.0.0.1:${server.port}`
+
+afterAll(() => server.stop(true))
+
+describe("fetchModels", () => {
+ test("returns the model ids from an OpenAI-shaped /v1/models response", async () => {
+ handler = () => Response.json({ data: [{ id: "a" }, { id: "b" }] })
+ seen.length = 0
+ const ids = await fetchModels("sk-test", base)
+ expect(ids).toEqual(["a", "b"])
+ // Hits /v1/models with a bearer token; base carried no /v1 suffix.
+ expect(seen.at(-1)).toEqual({ path: "/v1/models", auth: "Bearer sk-test" })
+ })
+
+ test("does not double the /v1 segment when the base already has one", async () => {
+ handler = () => Response.json({ data: [{ id: "a" }] })
+ seen.length = 0
+ await fetchModels("k", `${base}/v1`)
+ expect(seen.at(-1)?.path).toBe("/v1/models")
+ })
+
+ test("drops empty and non-string ids", async () => {
+ handler = () => Response.json({ data: [{ id: "a" }, { id: "" }, {}, { id: "b" }] })
+ const ids = await fetchModels("k", base)
+ expect(ids).toEqual(["a", "b"])
+ })
+
+ test("throws on a non-2xx response, surfacing the backend error", async () => {
+ handler = () => Response.json({ error: "bad key" }, { status: 401 })
+ await expect(fetchModels("k", base)).rejects.toThrow(/401.*bad key/)
+ })
+
+ test("throws when the gateway returns no models", async () => {
+ handler = () => Response.json({ data: [] })
+ await expect(fetchModels("k", base)).rejects.toThrow(/no models/)
+ })
+})
+
+describe("validateApiKey", () => {
+ test("returns true on a 2xx from /key/info at the gateway root", async () => {
+ handler = () => Response.json({ ok: true })
+ seen.length = 0
+ expect(await validateApiKey("sk-test", base)).toBe(true)
+ // /key/info lives at the root, so a trailing /v1 is stripped before joining.
+ expect(seen.at(-1)).toEqual({ path: "/key/info", auth: "Bearer sk-test" })
+ })
+
+ test("strips a trailing /v1 before appending /key/info", async () => {
+ handler = () => Response.json({ ok: true })
+ seen.length = 0
+ await validateApiKey("k", `${base}/v1`)
+ expect(seen.at(-1)?.path).toBe("/key/info")
+ })
+
+ test("returns false on 401/403 (invalid or revoked key)", async () => {
+ handler = () => new Response("nope", { status: 401 })
+ expect(await validateApiKey("k", base)).toBe(false)
+ handler = () => new Response("nope", { status: 403 })
+ expect(await validateApiKey("k", base)).toBe(false)
+ })
+
+ test("throws on other errors so callers can distinguish unreachable from bad key", async () => {
+ handler = () => new Response("boom", { status: 500 })
+ await expect(validateApiKey("k", base)).rejects.toThrow(/500/)
+ })
+})
diff --git a/packages/tui/test/custom-provider.view.test.tsx b/packages/tui/test/custom-provider.view.test.tsx
new file mode 100644
index 0000000000..32075d5105
--- /dev/null
+++ b/packages/tui/test/custom-provider.view.test.tsx
@@ -0,0 +1,227 @@
+import { expect, mock, test } from "bun:test"
+import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
+import { createTestRenderer } from "@opentui/core/testing"
+import { Effect } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { Global } from "@opencode-ai/core/global"
+import { createTuiResolvedConfig } from "./fixture/tui-runtime"
+import { createEventSource, createFetch, directory, json, type FetchHandler } from "./fixture/tui-sdk"
+
+const GATE = "Configure CoDev Code"
+
+async function readBody(input: RequestInfo | URL, init?: RequestInit) {
+ if (input instanceof Request) return input.clone().json().catch(() => undefined)
+ if (typeof init?.body === "string") return JSON.parse(init.body)
+ return undefined
+}
+
+async function waitFrame(setup: Awaited>, fn: (frame: string) => boolean) {
+ const start = Date.now()
+ while (true) {
+ await setup.renderOnce()
+ const frame = setup.captureCharFrame()
+ if (fn(frame)) return frame
+ if (Date.now() - start > 15_000) throw new Error("timed out waiting for frame:\n" + frame)
+ await Bun.sleep(25)
+ }
+}
+
+async function waitUntil(setup: Awaited>, fn: () => boolean) {
+ const start = Date.now()
+ while (!fn()) {
+ await setup.renderOnce()
+ if (Date.now() - start > 15_000) throw new Error("timed out waiting for condition")
+ await Bun.sleep(25)
+ }
+}
+
+// Boots the app with an injected fetch that captures the SDK writes the flow
+// makes (PUT /auth/{id}, PATCH /global/config) and answers instance.dispose,
+// delegating every other (sync) call to the default fixture. `override` can add
+// stateful provider responses. fetchModels uses the GLOBAL fetch, so the base
+// URL the test types must point at a real loopback server, not this one.
+async function boot(override?: FetchHandler, onConfigWrite?: (body: any) => void) {
+ const setup = await createTestRenderer({ width: 100, height: 30, useThread: false })
+ const core = await import("@opentui/core")
+ mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
+ const events = createEventSource()
+ const base = createFetch(override, events)
+ const captured = { auth: [] as { providerID: string; body: unknown }[], config: [] as any[] }
+
+ const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = new URL(input instanceof Request ? input.url : String(input))
+ const method = (input instanceof Request ? input.method : (init?.method ?? "GET")).toUpperCase()
+ if (method === "PUT" && url.pathname.startsWith("/auth/")) {
+ captured.auth.push({ providerID: decodeURIComponent(url.pathname.slice("/auth/".length)), body: await readBody(input, init) })
+ return json({})
+ }
+ if (method === "PATCH" && url.pathname === "/global/config") {
+ const body = await readBody(input, init)
+ captured.config.push(body)
+ // Flip fixture state before returning, so the flow's follow-up bootstrap
+ // sees the new provider (no race against the response resolving).
+ onConfigWrite?.(body)
+ return json({})
+ }
+ if (method === "POST" && url.pathname === "/instance/dispose") return json({})
+ return base.fetch(input, init)
+ }) as typeof globalThis.fetch
+
+ let api: TuiPluginApi | undefined
+ let started!: () => void
+ const ready = new Promise((resolve) => {
+ started = resolve
+ })
+ const { run } = await import("../src/app")
+ const task = Effect.runPromise(
+ run({
+ url: "http://test",
+ directory,
+ config: createTuiResolvedConfig({ plugin_enabled: {} }),
+ fetch,
+ events: events.source,
+ args: { continue: true },
+ pluginHost: {
+ async start(input) {
+ api = input.api
+ started()
+ },
+ async dispose() {},
+ },
+ }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
+ )
+ await ready
+ return { setup, task, captured, api: () => api }
+}
+
+async function teardown(boot_: Awaited>) {
+ boot_.api()?.keymap.dispatchCommand("app.exit")
+ await boot_.task
+ if (!boot_.setup.renderer.isDestroyed) boot_.setup.renderer.destroy()
+ mock.restore()
+}
+
+// Types a value into the focused prompt and submits it.
+async function answer(setup: Awaited>, value: string) {
+ if (value) await setup.mockInput.typeText(value)
+ setup.mockInput.pressEnter()
+}
+
+test("the gate offers SSO, Custom provider, and Connect provider", async () => {
+ const boot_ = await boot()
+ try {
+ const frame = await waitFrame(boot_.setup, (f) => f.includes(GATE))
+ expect(frame).toContain("Login with SSO")
+ expect(frame).toContain("Custom provider")
+ expect(frame).toContain("Connect provider")
+ } finally {
+ await teardown(boot_)
+ }
+})
+
+test("Connect provider opens the upstream connect dialog", async () => {
+ const boot_ = await boot()
+ try {
+ await waitFrame(boot_.setup, (f) => f.includes(GATE))
+ boot_.setup.mockInput.pressArrow("down")
+ boot_.setup.mockInput.pressArrow("down")
+ boot_.setup.mockInput.pressEnter()
+ await waitFrame(boot_.setup, (f) => f.includes("Connect a provider"))
+ } finally {
+ await teardown(boot_)
+ }
+})
+
+test("Custom provider with a base URL discovers models and writes the codev.json block", async () => {
+ const models = Bun.serve({
+ port: 0,
+ fetch: () => Response.json({ data: [{ id: "mock-small" }, { id: "mock-large" }] }),
+ })
+ const modelsBase = `http://127.0.0.1:${models.port}`
+ let configured = false
+ const provider = {
+ id: "mockgw",
+ name: "mockgw",
+ models: { "mock-small": { name: "mock-small" }, "mock-large": { name: "mock-large" } },
+ }
+ const boot_ = await boot(
+ (url) => {
+ // After the block is written, bootstrap must surface the provider so the
+ // flow lands in the model picker instead of the credential-only fallback.
+ if (url.pathname === "/config/providers")
+ return json(configured ? { providers: [provider], default: { mockgw: "mock-small" } } : { providers: [], default: {} })
+ if (url.pathname === "/provider")
+ return json(
+ configured
+ ? { all: [provider], default: {}, connected: ["mockgw"] }
+ : { all: [], default: {}, connected: [] },
+ )
+ return undefined
+ },
+ () => (configured = true),
+ )
+ try {
+ await waitFrame(boot_.setup, (f) => f.includes(GATE))
+ boot_.setup.mockInput.pressArrow("down") // Custom provider
+ boot_.setup.mockInput.pressEnter()
+
+ await waitFrame(boot_.setup, (f) => f.includes("Provider id"))
+ await answer(boot_.setup, "mockgw")
+ await waitFrame(boot_.setup, (f) => f.includes("API key"))
+ await answer(boot_.setup, "sk-mock")
+ await waitFrame(boot_.setup, (f) => f.includes("OpenAI-compatible endpoint"))
+ await answer(boot_.setup, modelsBase)
+
+ await waitUntil(boot_.setup, () => boot_.captured.config.length > 0)
+
+ // The credential goes to auth.json, keyless; the provider block goes to codev.json.
+ expect(boot_.captured.auth).toEqual([{ providerID: "mockgw", body: { type: "api", key: "sk-mock" } }])
+ expect(boot_.captured.config).toHaveLength(1)
+ expect(boot_.captured.config[0]).toEqual({
+ provider: {
+ mockgw: {
+ options: { baseURL: `${modelsBase}/v1` },
+ models: {
+ "mock-small": { name: "mock-small", limit: { context: 196608, output: 65536 } },
+ "mock-large": { name: "mock-large", limit: { context: 196608, output: 65536 } },
+ },
+ },
+ },
+ })
+
+ // Provider registered: the flow lands in the model picker for it (titled
+ // by the provider) rather than the credential-only toast. The picker's own
+ // model rendering is upstream's concern, so assert only that we reached it.
+ await waitFrame(boot_.setup, (f) => f.includes("mockgw") && f.includes("Search"))
+ } finally {
+ models.stop(true)
+ await teardown(boot_)
+ }
+}, 60_000)
+
+test("Custom provider with no base URL stores only the credential", async () => {
+ const boot_ = await boot()
+ try {
+ await waitFrame(boot_.setup, (f) => f.includes(GATE))
+ boot_.setup.mockInput.pressArrow("down") // Custom provider
+ boot_.setup.mockInput.pressEnter()
+
+ await waitFrame(boot_.setup, (f) => f.includes("Provider id"))
+ await answer(boot_.setup, "credx")
+ await waitFrame(boot_.setup, (f) => f.includes("API key"))
+ await answer(boot_.setup, "sk-cred")
+ await waitFrame(boot_.setup, (f) => f.includes("OpenAI-compatible endpoint"))
+ await answer(boot_.setup, "") // empty base URL -> credential-only
+
+ // The credential is stored; crucially, no provider block is written to
+ // codev.json (that is the whole point of the credential-only terminal).
+ await waitUntil(boot_.setup, () => boot_.captured.auth.length > 0)
+ // Let the rest of the flow (dispose + bootstrap) settle so a config write,
+ // if one were wrongly issued, would have landed.
+ await waitUntil(boot_.setup, () => boot_.captured.config.length > 0 || boot_.setup.captureCharFrame().includes(GATE))
+ expect(boot_.captured.auth).toEqual([{ providerID: "credx", body: { type: "api", key: "sk-cred" } }])
+ expect(boot_.captured.config).toHaveLength(0)
+ } finally {
+ await teardown(boot_)
+ }
+}, 60_000)