diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index 3023444c5a..5330b52e9c 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -38,7 +38,10 @@ upstream maintainers — see README → Syncing with upstream for why and how.
| `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/routes/session/index.tsx` | Empty-session banner: when the session has no messages, the transcript area shows the vertically centered `Logo` wordmark with the `home_bottom` tips slot beneath it (flexGrow spacer boxes), the session `Prompt` gets home's `placeholder` pool so the input shows an `Ask anything... ""` placeholder like home, and the scrollbox is clamped to `maxHeight` 1 until the first message (it holds its flex-derived height even when empty, so `flexGrow` 0 alone does not collapse it) | Startup lands in an empty session instead of the home screen; without this the transcript is a large blank void above the prompt. | Keep our banner block, the `placeholders` prop, and the conditional `maxHeight`; 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/plugin/src/tui.ts` | `home_bottom` slot props widened from `{}` to `{ placement?: "center" \| "start" }` | The empty-session banner asks `home_bottom` contributors to left-align; optional prop keeps the home route and third-party plugins (no prop → centered) unchanged. | Keep our `placement` prop; take upstream's other slot-map changes. |
+| `packages/tui/src/feature-plugins/home/tips.tsx` | `home_bottom` slot fn reads the new `placement` prop; with `"start"` the tips box left-aligns (`flex-start`) and drops its `paddingTop` 3 | The top-left session banner needs the tip flush under the logo; the centered home route keeps upstream spacing. | Keep our `placement` threading + conditional alignItems/paddingTop; take upstream's surrounding changes. |
+| `packages/tui/src/feature-plugins/system/which-key.tsx` | `HomeHint` reads the new `placement` prop from the `home_bottom` slot; with `"start"` it left-aligns | Same top-left banner alignment as the tips row above. | Keep our `placement` threading + conditional alignItems; take upstream's surrounding changes. |
| `packages/tui/src/routes/home.tsx` | `placeholder` suggestion pool exported (was module-private) | The empty-session view in routes/session/index.tsx feeds the same pool to its prompt, so upstream edits to the suggestions flow through. | Keep the `export` keyword; take upstream's changes to the list. |
| `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); permission.tsx also registers a local `app.exit.confirm` command mirroring its `app.exit` reject and gathers the confirm binding (double-press Ctrl+C — see the double-Ctrl+C row) | User-facing copy; keep Ctrl+C rejecting the permission prompt as before. | Keep our strings; keep permission.tsx's `app.exit.confirm` command + binding. |
| `packages/tui/src/config/keybind.ts`, `packages/tui/src/routes/session/question.tsx`, `packages/tui/src/ui/toast.tsx`, `packages/tui/test/app-lifecycle.test.tsx`, `packages/web/src/content/docs/keybinds.mdx` (+ `es/`) | Double-press Ctrl+C to quit. keybind.ts: `app_exit` `ctrl+c,ctrl+d,q` → `ctrl+d,q`, added `app_exit_confirm: "ctrl+c"` definition + its `app.exit.confirm` CommandMap entry. question.tsx: a local `app.exit.confirm` command mirroring its `app.exit` reject, with the confirm binding gathered in both option branches (Ctrl+C still rejects the question — single press). toast.tsx: `ToastOptions.variant` union gains `"secondary"` (resolves to `theme.secondary`, the banner brand color, for the confirm toast). app-lifecycle.test.tsx: added the "app.exit.confirm requires two presses to exit" test. Docs (en + es `keybinds.mdx`): split the `app_exit` default and added `app_exit_confirm: "ctrl+c"`. See also the `app.tsx`, `dialog.tsx`, and `permission.tsx` rows for their `app.exit.confirm` handlers. | A stray Ctrl+C on an empty/unfocused prompt no longer quits instantly: the first press shows a "Press Ctrl+C again to exit" toast (1.5 s window), the second within that window quits. Ctrl+D, `q`, and the `/exit` slash commands stay immediate. | Keep our binding split, the `app_exit_confirm`/`app.exit.confirm` definition + mapping, the local confirm commands + gathered bindings, the toast `"secondary"` variant, the test, and the doc defaults; take upstream's surrounding changes. If upstream re-adds `ctrl+c` to `app_exit`, keep it removed. |
diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts
index 6781216947..36ff8ab5f9 100644
--- a/packages/plugin/src/tui.ts
+++ b/packages/plugin/src/tui.ts
@@ -470,7 +470,9 @@ export type TuiHostSlotMap = {
session_prompt_right: {
session_id: string
}
- home_bottom: {}
+ home_bottom: {
+ placement?: "center" | "start"
+ }
home_footer: {}
sidebar_title: {
session_id: string
diff --git a/packages/tui/src/feature-plugins/home/tips.tsx b/packages/tui/src/feature-plugins/home/tips.tsx
index 9a853c371d..c7617e1b2b 100644
--- a/packages/tui/src/feature-plugins/home/tips.tsx
+++ b/packages/tui/src/feature-plugins/home/tips.tsx
@@ -6,7 +6,13 @@ import { useBindings } from "../../keymap"
const id = "internal:home-tips"
-function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) {
+function View(props: {
+ api: TuiPluginApi
+ hidden: boolean
+ show: boolean
+ connected: boolean
+ placement?: "center" | "start"
+}) {
useBindings(() => ({
commands: [
{
@@ -24,7 +30,13 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec
}))
return (
-
+
@@ -36,7 +48,7 @@ const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
- home_bottom() {
+ home_bottom(_ctx, props) {
const hidden = createMemo(() => api.kv.get("tips_hidden", false))
const first = createMemo(() => api.state.session.count() === 0)
const connected = createMemo(() =>
@@ -45,7 +57,7 @@ const tui: TuiPlugin = async (api) => {
),
)
const show = createMemo(() => (!first() || !connected()) && !hidden())
- return
+ return
},
},
})
diff --git a/packages/tui/src/feature-plugins/system/which-key.tsx b/packages/tui/src/feature-plugins/system/which-key.tsx
index d16dfc28d2..646c02ff53 100644
--- a/packages/tui/src/feature-plugins/system/which-key.tsx
+++ b/packages/tui/src/feature-plugins/system/which-key.tsx
@@ -168,12 +168,18 @@ function layout(value: unknown): Layout {
return "dock"
}
-function HomeHint(props: { api: TuiPluginApi }) {
+function HomeHint(props: { api: TuiPluginApi; placement?: "center" | "start" }) {
const trigger = commandShortcut(props.api, command.toggle)
const look = createMemo(() => skin(props.api))
return (
-
+
Show keyboard shortcuts with {trigger() || command.toggle}
@@ -578,8 +584,8 @@ const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 200,
slots: {
- home_bottom() {
- return
+ home_bottom(_ctx, props) {
+ return
},
app() {
return (
diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx
index 88ab2f4a87..87bf1be39e 100644
--- a/packages/tui/src/routes/session/index.tsx
+++ b/packages/tui/src/routes/session/index.tsx
@@ -337,6 +337,10 @@ export function Session() {
let seeded = false
let scroll: ScrollBoxRenderable
+ // Flow layout: the transcript is clamped to its content height so the
+ // prompt rides directly below the last message and only reaches the
+ // bottom once the content fills the screen.
+ const [transcriptHeight, setTranscriptHeight] = createSignal(1)
let prompt: PromptRef | undefined
const bind = (r: PromptRef | undefined) => {
prompt = r
@@ -349,6 +353,30 @@ export function Session() {
const dialog = useDialog()
const renderer = useRenderer()
+ onMount(() => {
+ // layout-changed fires right after yoga computed the layout but before it
+ // is applied and painted, so this is the one spot where the content clamp
+ // can land in the same frame. scroll.scrollHeight still holds last
+ // frame's value here; read the fresh height off the yoga node instead,
+ // then re-run the layout so the frame paints with the updated clamp.
+ // Painting with a stale clamp scrolls the transcript up and back down a
+ // frame later — visible flicker while streaming into a short transcript.
+ let applied = 0
+ let applying = false
+ const measure = () => {
+ if (!scroll || applying) return
+ const height = Math.max(1, Math.ceil(scroll.content.getLayoutNode().getComputedHeight()))
+ if (height === applied) return
+ applied = height
+ applying = true
+ setTranscriptHeight(height)
+ renderer.root.calculateLayout()
+ applying = false
+ }
+ renderer.root.on("layout-changed", measure)
+ onCleanup(() => renderer.root.off("layout-changed", measure))
+ })
+
event.on("session.status", (evt) => {
if (evt.properties.sessionID !== route.sessionID) return
if (evt.properties.status.type !== "retry") return
@@ -1167,17 +1195,6 @@ export function Session() {
- {/* Fork: startup lands in an empty session instead of the home
- screen, so fill the transcript void with a home-like banner
- until the first message arrives. */}
-
-
-
-
-
-
-
-
(scroll = r)}
viewportOptions={{
@@ -1194,12 +1211,19 @@ export function Session() {
stickyScroll={true}
stickyStart="bottom"
flexGrow={1}
- // The scrollbox holds its flex-derived height even when empty,
- // so clamp it while the banner is shown.
- maxHeight={messages().length === 0 ? 1 : undefined}
+ // The scrollbox holds its flex-derived height even when its
+ // content is shorter, so clamp it to the content.
+ maxHeight={transcriptHeight()}
scrollAcceleration={scrollAcceleration()}
>
-
+ {/* Fork: the transcript opens with a home-like banner that
+ scrolls away with the history. */}
+ }>
+
+
+
+
+
{(message, index) => (
@@ -1335,6 +1359,9 @@ export function Session() {
+ {/* Absorbs the leftover space below the prompt while the
+ clamped transcript is shorter than the screen. */}
+