Skip to content
Merged
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
2 changes: 1 addition & 1 deletion DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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 \|"; 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... "<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 kv default `"auto"` → `"hide"` (the `kv.signal<"auto" \| "hide">("sidebar", …)` initial value) | The right sidebar starts hidden regardless of terminal width; `ctrl+b`/`<leader>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 `<Prompt>` (all additive); take upstream's surrounding changes. If upstream starts passing its own `hint` to this `<Prompt>`, merge the two under one `Show`. |
Expand Down
25 changes: 14 additions & 11 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -592,16 +592,19 @@ function App(props: { onSnapshot?: () => Promise<string[]>; 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(() => <DialogConfigure />)
},
),
)
// 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(() => <DialogConfigure />)
})

// Fork: `codev configure` re-opens the gateway configure dialog on demand.
onMount(() => {
Expand Down
15 changes: 15 additions & 0 deletions packages/tui/src/component/configure-gate.ts
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 7 additions & 6 deletions packages/tui/src/component/dialog-configure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => <DialogProvider />),
},
]}
Expand Down
22 changes: 22 additions & 0 deletions packages/tui/test/configure-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
156 changes: 156 additions & 0 deletions packages/tui/test/configure-gate.view.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createTestRenderer>>, 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<void>((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)
Loading