Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ file is the human-readable summary of what changed and why.

### Gateway

- **The terminal app notices a dead gateway key at startup** — gateway keys
have a lifetime CoDev does not control, and until now only the desktop app
checked the stored one when it launched. In the TUI a key the gateway had
since rejected left everything configured on paper and failing on every
message, with `/configure` as the only way back — if you knew to run it.
`codev` now runs the same pre-flight as the desktop app once sync settles:
a rejected key is re-minted silently while the CoDev sign-in still allows
it, and only a key that cannot be renewed without a browser opens the
configure dialog — dismissable, since a provider is configured and it is just
the key that died. Machines set up with some other provider are left alone.
([#352](https://github.com/quickbeard/codev-code/pull/352))
- **The desktop sign-in keeps its fallback controls** — choosing **Login with
SSO** in the desktop app showed the **Open link**, **Copy** and paste-back
controls for a split second and then hid them, because the status line
Expand Down
3 changes: 2 additions & 1 deletion DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ from the squash message first, or use a plain merge.
| `packages/tui/src/feature-plugins/sidebar/footer.tsx` | Added the editor-context label above the `cwd:branch` line: `useEditorLabel()` (fork-only `src/context/editor-label.tsx`) supplies the effective selection, an `editorFile` memo formats it via `editorContextFileLabel(...)` at a fixed width of 36 (the sidebar's 42 columns less 2 of padding a side and 2 for the bullet), and `<EditorContextLabel>` renders it — both from the fork-only `./editor-context-label.tsx`. | The open file + selected range belong with the sidebar's other ambient session state rather than in the prompt footer's crowded keybind-hint row; they sit next to the cwd for a single "where am I working" group. Moved out of the prompt footer, which no longer shows it at all. | Keep the `useEditorLabel` import, the `editorFile` memo, and the `<EditorContextLabel>` element as the box's first child; take upstream's changes to the path display below it. |
| `packages/tui/src/feature-plugins/sidebar/lsp.tsx` | Whole LSP section wrapped in `<Show when={!off()}>` so it renders nothing when `config.lsp` is falsy; the "LSPs are disabled" placeholder text removed (empty state is only "LSPs will activate as files are read") | A disabled feature shouldn't occupy sidebar space with a permanent placeholder. | Keep our `Show` wrapper + removed placeholder; 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 \|"; the configure-gate effect is a stateless forced-setup invariant — while `sync.status === "complete" && !useConnected() && dialog.stack.length === 0` it (re)opens the fork's `DialogConfigure` (component/dialog-configure.tsx, new file), so escaping any gate-launched flow or a credential-only save bounces back to the gate instead of an unconfigured chat (gates on `!useConnected()`, not `provider.length === 0` — hosted Zen free models always register); added the `codev.configure` command (`/configure`) and a `CODEV_CONFIGURE=1` mount hook for `codev configure`; home screen skipped — plain startup seeds the `dummy` session route and auto-opens a new empty session (reusing a leftover default-titled empty one via `openNewSession`/`findEmptySession`), `session.new` and the current-session `session.deleted` handler open a new session instead of navigating home, and the `-c` resolver prefers non-default-titled sessions so it lands on the last real conversation; added the `app.exit.confirm` command + `EXIT_CONFIRM_WINDOW` const (double-press Ctrl+C to quit: first press shows a toast, second within 1.5 s exits) and gathered its `app_exit_confirm` binding alongside `app_exit`; an onMount "Session upload is paused" warning toast next to the raster-font one, shown when `@codev/upload/nudge`'s `shouldNudgeSignIn()` says the background upload stalled for lack of a CoDev SSO session (SSO-origin keys only; deliberately every launch until the sign-in happens — see the session-upload wiring row) | Tab title branding; first-run setup is a forced gate the user can't escape into an unconfigured chat (the invariant reopens it); `codev` starts directly in the session layout instead of the home screen; a stray Ctrl+C no longer quits on the first press. | Keep our strings, the invariant gate effect, dialog swap + command block, route seed, `openNewSession`/`findEmptySession` + call sites, the `-c` default-title filter, and the `app.exit.confirm` command + binding; take upstream's surrounding changes. |
| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; the configure-gate effect is a stateless forced-setup invariant — while `sync.status === "complete" && !useConnected() && dialog.stack.length === 0` it (re)opens the fork's `DialogConfigure` (component/dialog-configure.tsx, new file), so escaping any gate-launched flow or a credential-only save bounces back to the gate instead of an unconfigured chat (gates on `!useConnected()`, not `provider.length === 0` — hosted Zen free models always register); added the `codev.configure` command (`/configure`) and a `CODEV_CONFIGURE=1` mount hook for `codev configure`; home screen skipped — plain startup seeds the `dummy` session route and auto-opens a new empty session (reusing a leftover default-titled empty one via `openNewSession`/`findEmptySession`), `session.new` and the current-session `session.deleted` handler open a new session instead of navigating home, and the `-c` resolver prefers non-default-titled sessions so it lands on the last real conversation; added the `app.exit.confirm` command + `EXIT_CONFIRM_WINDOW` const (double-press Ctrl+C to quit: first press shows a toast, second within 1.5 s exits) and gathered its `app_exit_confirm` binding alongside `app_exit`; an onMount "Session upload is paused" warning toast next to the raster-font one, shown when `@codev/upload/nudge`'s `shouldNudgeSignIn()` says the background upload stalled for lack of a CoDev SSO session (SSO-origin keys only; deliberately every launch until the sign-in happens — see the session-upload wiring row); a one-shot `createEffect` after `sync.status === "complete"` runs `@codev/gateway`'s `ensureGatewayKey()` (the startup gateway-key pre-flight shared with the desktop controller): a `refreshed` result merges the re-minted key into the auth registry for every id it names via `sdk.client.auth.set`, then `instance.dispose()` + `sync.bootstrap()`; a `needs-login` with reason `rejected` opens `<DialogConfigure dismissable />` when no dialog is up (`no-key` is ignored — a non-gateway setup, or the gate's own case) | Tab title branding; first-run setup is a forced gate the user can't escape into an unconfigured chat (the invariant reopens it); `codev` starts directly in the session layout instead of the home screen; a stray Ctrl+C no longer quits on the first press. | Keep our strings, the invariant gate effect, dialog swap + command block, route seed, `openNewSession`/`findEmptySession` + call sites, the `-c` default-title filter, and the `app.exit.confirm` command + binding; take upstream's surrounding changes. |
| `packages/tui/bunfig.toml` | `[test] preload` gains the fork-only `./test/preload.ts`, which sets `CODEV_TEST_HOME` to a fresh temp dir (unless already set) before any test runs | The app mount runs the gateway key pre-flight (`ensureGatewayKey`) and the upload sign-in nudge at startup; both read the shared auth file under `~/.codev`/`~/.codev-hub`, and the pre-flight can write a re-minted key back. View tests boot the real app, so without an isolated home a developer's test run would probe the real gateway with their real key. Same convention as core's `Global` and `packages/opencode/test/preload.ts`. | Keep our preload entry; take upstream's other bunfig changes. |
| `packages/tui/src/routes/session/index.tsx` | Flow transcript: the scrollbox opens with a home-like banner (`Logo` + `home_bottom` tips slot, `placement="start"`) as its first entry for root sessions (subagent sessions keep a plain 1-row pad), its `maxHeight` is clamped to the content height (a `transcriptHeight` signal read off the content's yoga node in the renderer root's `layout-changed` handler, which then re-runs `calculateLayout` so the clamp lands in the same frame — `scrollHeight` is a frame stale there, and painting with a stale clamp flickers during streaming) so the `Prompt` rides directly below the last message, and a trailing flexGrow spacer absorbs the leftover space until content fills the screen (then the prompt is effectively bottom-anchored and the banner scrolls away with history); the session `Prompt` gets home's `placeholder` pool when the session has no messages so the input shows an `Ask anything... "<suggestion>"` placeholder like home | Startup lands in an empty session instead of the home screen; the transcript flows top-down like terminal scrollback — logo top-left, input following the conversation down — instead of a bottom-anchored prompt with a void above it. | Keep our banner block inside the scrollbox, the `transcriptHeight` clamp + `layout-changed` listener, the trailing spacer, and the `placeholders` prop; take upstream's surrounding changes. |
| `packages/tui/src/routes/session/index.tsx` | Sidebar visibility is session-local: removed upstream's persisted `kv.signal<"auto" \| "hide">("sidebar", "auto")` (and the `sidebar() === "auto" && wide()` branch of `sidebarVisible`); visibility is driven only by the non-persisted `sidebarOpen` signal, which the `session.sidebar.toggle` command flips (supersedes the earlier `"auto"` → `"hide"` default change) | The right sidebar starts hidden in every session regardless of terminal width or what the user did last session; `ctrl+b`/`<leader>b` only affects the current session. A stale `sidebar` key in existing kv.json files is simply never read. | Keep the kv signal removed and `sidebarVisible` reading only `parentID` + `sidebarOpen`; take upstream's surrounding changes. |
| `packages/tui/src/routes/session/index.tsx` | Hidden-sidebar surfaces: a `sidebarHint` memo (modified-file count from `sync.data.session_diff`, only when the sidebar is hidden and the session isn't a subagent; label from `sidebarHintLabel` in the fork-only `routes/session/sidebar-hint.ts`) plus a `sidebarShortcut` (`useCommandShortcut("session.sidebar.toggle")`), passed as the `Prompt`'s previously unused `hint` prop — e.g. `ctrl+b 5 modified files`; and a compact `<TodoPanel>` (fork-only `routes/session/todo-panel.tsx` + `todo-panel-model.ts`) mounted inside `<Show when={visible()}>` under an extra `<Show when={!sidebarVisible()}>`, directly above the `session_prompt` slot — up to 4 checkbox items windowed around the active todo plus a summary line (`2 of 6 finished · ctrl+b to show full TODO`), shown only while an unfinished todo exists | The sidebar starts hidden in this fork (see the row above), so users would otherwise never see the agent's plan or modified files or how to reveal them. The panel shows the active todos inline, so the footer hint only needs to cover modified files. | Keep our `sidebarShortcut`/`sidebarHint` memos, the `sidebar-hint` and `todo-panel` imports, the `TodoPanel` mount above the `session_prompt` slot, and the `hint={…}` prop on `<Prompt>` (all additive); take upstream's surrounding changes. If upstream starts passing its own `hint` to this `<Prompt>`, merge the two under one `Show`; if upstream adds its own children to the bottom box, keep `TodoPanel` directly above the prompt slot. |
Expand Down
100 changes: 100 additions & 0 deletions packages/codev-gateway/src/ensure-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { loadApiKey, loadGatewayUrl, saveApiKey, type AuthData } from "./auth-file"
import { fetchApiKey, validateApiKey } from "./backend"
import { GATEWAY_PROVIDER, LEGACY_GATEWAY_PROVIDER_IDS } from "./provider"
import { silentSso } from "./sso"

// Cap the key check so a slow or hung gateway never delays startup. A real
// /key/info round-trip is far under this; past it we cannot prove the key is
// dead, so we leave it alone (mirrors codev-hub's launch pre-flight).
const KEY_CHECK_TIMEOUT_MS = 2_500

export type GatewayKeyCheck =
// Key validated, or the gateway could not be reached to say otherwise.
| { status: "ok" }
// A rejected key was re-minted silently. Every id in providerIDs needs the
// new credential in the local server's auth registry: the current SSO
// identity plus its pre-rename spellings, so a config block written before
// an identity rename is re-keyed too instead of 401ing on a credential
// nothing updates. baseUrl and auth are what a caller that tracks the
// signed-in session (the desktop controller) adopts from the refresh.
| { status: "refreshed"; providerIDs: string[]; apiKey: string; baseUrl: string; auth: AuthData }
// "no-key": nothing stored — a machine set up with a non-gateway provider,
// or never set up. "rejected": the gateway refused the stored key and the
// SSO session could not re-mint it without a browser sign-in.
| { status: "needs-login"; reason: "no-key" | "rejected" }

export type EnsureKeyFns = {
loadApiKey: typeof loadApiKey
loadGatewayUrl: typeof loadGatewayUrl
validateApiKey: typeof validateApiKey
silentSso: typeof silentSso
fetchApiKey: typeof fetchApiKey
saveApiKey: typeof saveApiKey
}

export interface EnsureKeyOptions {
// Electron callers pass the host-aware net.fetch so proxies and PAC files
// apply to the check and the re-mint alike.
fetchImpl?: typeof fetch
timeoutMs?: number
// Injectable for tests; defaults to this package's own functions.
fns?: Partial<EnsureKeyFns>
}

// Startup pre-flight for the stored gateway key, the client twin of
// codev-hub's ensureFreshGatewayKey. Gateway keys have a lifetime CoDev does
// not control, and nothing else notices a dead one: the provider stays
// "connected" with a credential that 401s on every message, and the configure
// gate only fires when no provider is connected at all — so the sign-in flow
// was unreachable exactly when it was needed.
//
// A rejected key is refreshed silently when the SSO session still allows it,
// which is the common case and costs the user nothing; the refreshed key is
// written to the shared auth file here, and the caller merges it into the
// local server's auth registry. Only a key that cannot be refreshed without a
// browser round-trip asks for one. Never throws on network trouble: only a
// definite rejection acts, because a gateway that is slow or unreachable must
// never cost someone their session.
export async function ensureGatewayKey(options: EnsureKeyOptions = {}): Promise<GatewayKeyCheck> {
const fns: EnsureKeyFns = {
loadApiKey,
loadGatewayUrl,
validateApiKey,
silentSso,
fetchApiKey,
saveApiKey,
...options.fns,
}
const fetchOpts = { fetchImpl: options.fetchImpl }
const creds = fns.loadApiKey()
if (!creds?.apiKey) return { status: "needs-login", reason: "no-key" }
// SSO-minted keys record no base_url (the configure flows write it for
// manual keys only), so fall back to the gateway URL saved with the config
// block.
const baseUrl = creds.baseUrl ?? fns.loadGatewayUrl()
if (!baseUrl) return { status: "needs-login", reason: "no-key" }

// true = valid, false = rejected, null = could not tell.
const valid = await Promise.race([
fns.validateApiKey(creds.apiKey, baseUrl, fetchOpts).catch(() => null),
new Promise<null>((resolve) => setTimeout(resolve, options.timeoutMs ?? KEY_CHECK_TIMEOUT_MS, null)),
])
if (valid !== false) return { status: "ok" }

const auth = await fns.silentSso(fetchOpts).catch(() => null)
if (!auth) return { status: "needs-login", reason: "rejected" }
const refreshed = await fns.fetchApiKey(auth.access_token, fetchOpts).catch(() => "")
if (!refreshed) return { status: "needs-login", reason: "rejected" }

// Keep every other field: the provider identity and model are what a
// manually named provider is rewritten from, and dropping them would
// silently re-label it.
fns.saveApiKey({ ...creds, apiKey: refreshed })
return {
status: "refreshed",
providerIDs: [GATEWAY_PROVIDER.id, ...LEGACY_GATEWAY_PROVIDER_IDS],
apiKey: refreshed,
baseUrl,
auth,
}
}
1 change: 1 addition & 0 deletions packages/codev-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
type ModelWindow,
} from "./backend"
export { cacheModelLimits, limitsFor, type ModelLimits } from "./model-limits"
export { ensureGatewayKey, type EnsureKeyFns, type EnsureKeyOptions, type GatewayKeyCheck } from "./ensure-key"
export { buildCustomProviderConfig, buildGatewayConfig, normalizeGatewayBaseUrl } from "./config-block"
export {
CUSTOM_FALLBACK_PROVIDER,
Expand Down
Loading
Loading