diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 60730cef52..5926f05b63 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -84,7 +84,7 @@ from the squash message first, or use a plain merge. | `packages/tui/src/feature-plugins/sidebar/footer.tsx` | Removed the footer's brand + version line entirely (upstream's two-tone "Open"+"Code" spans, status dot, and `app.version`); the `cwd:branch` path is now the last footer item | The fork's sidebar doesn't advertise the product name/version; the path takes that bottom slot. (Supersedes the earlier single-"CoDev"-span rebrand of this line.) | Keep the line deleted; take upstream's surrounding changes. | | `packages/tui/src/feature-plugins/sidebar/lsp.tsx` | Whole LSP section wrapped in `` 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 \|"; 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`; 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` | Tab title branding; first-run setup routes to the CoDev gateway configure flow (upstream's `/connect` stays as the escape hatch); `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, gate condition, 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` | 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/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... ""` 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 kv default `"auto"` → `"hide"` (the `kv.signal<"auto" \| "hide">("sidebar", …)` initial value) | The right sidebar starts hidden regardless of terminal width; `ctrl+b`/`b` opens it, and the choice persists in kv as before. | Keep our `"hide"` default; take upstream's surrounding changes. | | `packages/tui/src/routes/session/index.tsx` | Sidebar hint in the prompt footer: a `sidebarHint` memo (active-todo count from `sync.data.todo`, modified-file count from `sync.data.session_diff`, only when the sidebar is hidden and the session isn't a subagent; the label text comes 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 3 todos · 5 files` | The sidebar starts hidden in this fork (see the row above), so users would otherwise never learn there are todos/modified files or how to reveal them. | Keep our `sidebarShortcut`/`sidebarHint` memos, the `sidebar-hint` import, and the `hint={…}` prop on `` (all additive); take upstream's surrounding changes. If upstream starts passing its own `hint` to this ``, merge the two under one `Show`. | diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index ad7621c427..78ea76e0f9 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -22,12 +22,12 @@ import { onCleanup, batch, Show, - on, } from "solid-js" import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" import { DialogProvider as DialogProviderList } from "./component/dialog-provider" import { DialogConfigure } from "./component/dialog-configure" +import { shouldOpenConfigureGate } from "./component/configure-gate" import { ErrorComponent } from "./component/error-component" import { PluginRouteMissing } from "./component/plugin-route-missing" import { ProjectProvider, useProject } from "./context/project" @@ -592,16 +592,19 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // 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" && !connected(), - (unconfigured, wasUnconfigured) => { - // only trigger when we transition into an unconfigured state - if (!unconfigured || wasUnconfigured) return - dialog.replace(() => ) - }, - ), - ) + // Fork: the configure gate is a forced first-run setup — it re-asserts itself + // whenever we're unconfigured and no dialog is open. Escaping any + // gate-launched flow (Custom provider / Connect provider, at any depth), a + // credential-only save, or any error bounces back here instead of landing in + // an unconfigured chat; only a real provider (connected() true) or quitting + // (double Ctrl+C) leaves the gate. A stateless invariant like this subsumes + // the one-shot open-on-startup trigger and needs no per-step escape handling + // inside the flows (Connect provider is upstream's dialog — untouched). + createEffect(() => { + if (!shouldOpenConfigureGate({ syncComplete: sync.status === "complete", connected: connected(), dialogOpen: dialog.stack.length > 0 })) + return + dialog.replace(() => ) + }) // Fork: `codev configure` re-opens the gateway configure dialog on demand. onMount(() => { diff --git a/packages/tui/src/component/configure-gate.ts b/packages/tui/src/component/configure-gate.ts new file mode 100644 index 0000000000..2e62cb396e --- /dev/null +++ b/packages/tui/src/component/configure-gate.ts @@ -0,0 +1,15 @@ +// Fork-only: the open-condition for the forced first-run configure gate. +// +// The gate re-asserts itself whenever setup is incomplete and nothing else is +// on screen, so escaping any gate-launched flow (Custom provider / Connect +// provider) or a credential-only save bounces back to it instead of an +// unconfigured chat. Split out as a pure predicate so app.tsx's effect reads as +// a single intent and the three conditions are unit-testable in isolation: +// - syncComplete: `connected` only reflects real provider state once sync has +// loaded; before that the provider list is transiently empty. +// - connected: a real (non-free) provider ends the forced setup. +// - dialogOpen: never stack on top of an in-progress step — only reopen +// once the stack has emptied. +export function shouldOpenConfigureGate(input: { syncComplete: boolean; connected: boolean; dialogOpen: boolean }) { + return input.syncComplete && !input.connected && !input.dialogOpen +} diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index 2aa598e61f..4ca23f625f 100644 --- a/packages/tui/src/component/dialog-configure.tsx +++ b/packages/tui/src/component/dialog-configure.tsx @@ -77,18 +77,19 @@ export function DialogConfigure() { { title: "Custom provider", value: "custom", - // The shared flow is dismissable like option 3 — escaping lands in - // an unconfigured chat; /configure and /connect finish setup later. + // The shared flow's steps stay individually dismissable; escaping (or + // a credential-only save) empties the dialog stack, and app.tsx's + // unconfigured invariant bounces back to this gate rather than to an + // unconfigured chat. onSelect: () => void runCustomProviderFlow(ctx), }, { title: "Connect provider", value: "provider", description: "Anthropic, OpenAI, and others", - // Upstream's connect flow isn't built for the persistent gate - // (replace() unlocks it), so escaping here lands in an unconfigured - // chat — same as upstream's first-run behavior; /configure and - // /connect remain available to finish setup. + // Upstream's connect dialog is left untouched (its steps stay + // dismissable); escaping out of it while still unconfigured trips + // app.tsx's invariant and reopens this gate. onSelect: () => ctx.dialog.replace(() => ), }, ]} diff --git a/packages/tui/test/configure-gate.test.ts b/packages/tui/test/configure-gate.test.ts new file mode 100644 index 0000000000..9a30d5c897 --- /dev/null +++ b/packages/tui/test/configure-gate.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { shouldOpenConfigureGate } from "../src/component/configure-gate" + +describe("shouldOpenConfigureGate", () => { + test("opens only when setup is complete, no provider is connected, and no dialog is open", () => { + expect(shouldOpenConfigureGate({ syncComplete: true, connected: false, dialogOpen: false })).toBe(true) + }) + + test("stays closed until sync has loaded (connected is not yet meaningful)", () => { + expect(shouldOpenConfigureGate({ syncComplete: false, connected: false, dialogOpen: false })).toBe(false) + }) + + test("stays closed once a real provider is connected", () => { + expect(shouldOpenConfigureGate({ syncComplete: true, connected: true, dialogOpen: false })).toBe(false) + // A configured user running /configure or /connect is never re-trapped. + expect(shouldOpenConfigureGate({ syncComplete: true, connected: true, dialogOpen: true })).toBe(false) + }) + + test("never stacks on top of an in-progress step — waits for the stack to empty", () => { + expect(shouldOpenConfigureGate({ syncComplete: true, connected: false, dialogOpen: true })).toBe(false) + }) +}) diff --git a/packages/tui/test/configure-gate.view.test.tsx b/packages/tui/test/configure-gate.view.test.tsx new file mode 100644 index 0000000000..79eac7fdb0 --- /dev/null +++ b/packages/tui/test/configure-gate.view.test.tsx @@ -0,0 +1,156 @@ +import { expect, mock, test } from "bun:test" +import type { TuiPluginApi, TuiSlotPlugin } 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, worktree, type FetchHandler } from "./fixture/tui-sdk" + +const GATE = "Configure CoDev Code" + +// A listed session so the chat view has something to render in the configured case. +const session = { + id: "ses_gate", + title: "Demo session", + slug: "gate", + projectID: "proj_test", + directory, + version: "0.0.0-test", + time: { created: 1, updated: 1 }, +} + +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) + } +} + +// Boots the real app against a fetch fixture and hands back the running task, +// the render harness, and the plugin api. `registerSidebar` mirrors the real +// host wiring so the chat view actually paints (needed to assert the gate is +// absent in the configured case); the gate itself is a dialog overlay and +// paints without it. +async function boot(options: { override?: FetchHandler; registerSidebar?: boolean }) { + 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 calls = createFetch(options.override, events) + 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: calls.fetch, + events: events.source, + args: { continue: true }, + pluginHost: { + async start(input) { + if (options.registerSidebar) { + const hostSlots = input.runtime.setupSlots(input.api) + const { createBuiltinPlugins } = await import("../src/feature-plugins/builtins") + const sidebar = createBuiltinPlugins({ experimentalEventSystem: false }).filter((plugin) => + plugin.id.startsWith("internal:sidebar-"), + ) + for (const plugin of sidebar) { + const scoped = { + ...input.api, + slots: { + register: (registration: TuiSlotPlugin) => { + hostSlots.register({ ...registration, id: plugin.id }) + return plugin.id + }, + }, + } as typeof input.api + await plugin.tui(scoped, undefined, {} as never) + } + } + api = input.api + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + await ready + return { setup, task, events, api: () => api } +} + +test("forced gate opens on an unconfigured startup", async () => { + const boot_ = await boot({}) + try { + // No provider is registered (default fixture), so useConnected() is false + // and the invariant opens the gate on top of the empty chat. + await waitFrame(boot_.setup, (frame) => frame.includes(GATE)) + } finally { + boot_.api()?.keymap.dispatchCommand("app.exit") + await boot_.task + if (!boot_.setup.renderer.isDestroyed) boot_.setup.renderer.destroy() + mock.restore() + } +}) + +test("escaping the Connect-provider dialog bounces back to the gate", async () => { + const boot_ = await boot({}) + try { + await waitFrame(boot_.setup, (frame) => frame.includes(GATE)) + + // Selecting the gate's "Connect provider" option runs exactly this: replace + // the gate with upstream's (dismissable) connect dialog. + boot_.api()?.keymap.dispatchCommand("provider.connect") + await waitFrame(boot_.setup, (frame) => frame.includes("Connect a provider") && !frame.includes(GATE)) + + // Escape empties the dialog stack; still unconfigured, so the invariant + // re-opens the gate instead of leaving the user in an unconfigured chat. + boot_.setup.mockInput.pressEscape() + await waitFrame(boot_.setup, (frame) => frame.includes(GATE) && !frame.includes("Connect a provider")) + } finally { + boot_.api()?.keymap.dispatchCommand("app.exit") + await boot_.task + if (!boot_.setup.renderer.isDestroyed) boot_.setup.renderer.destroy() + mock.restore() + } +}) + +test("a configured provider keeps the gate closed", async () => { + const boot_ = await boot({ + registerSidebar: true, + override(url) { + if (url.pathname === "/session") return json([session]) + if (url.pathname.startsWith(`/session/${session.id}/message`)) return json([]) + if (url.pathname === `/session/${session.id}`) return json(session) + if (url.pathname === "/project/proj_test/directories") return json([]) + // A paid model registers a real provider, so useConnected() is true. + if (url.pathname === "/config/providers") + return json({ + providers: [{ id: "test", name: "Test", models: { m1: { name: "M1", cost: { input: 1 } } } }], + default: { test: "m1" }, + }) + return undefined + }, + }) + try { + // Reaching the chat footer proves sync completed and the app settled; the + // gate must never have opened over it. + const frame = await waitFrame(boot_.setup, (f) => f.includes("ctrl+p commands")) + expect(frame).not.toContain(GATE) + } finally { + boot_.api()?.keymap.dispatchCommand("app.exit") + await boot_.task + if (!boot_.setup.renderer.isDestroyed) boot_.setup.renderer.destroy() + mock.restore() + } +}, 60_000)