From e79f6f394c92bde1966f2689c0254a5b01d76aae Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 13 Jul 2026 15:20:41 +0700 Subject: [PATCH 1/4] feat(tui): move landing banner top-left with prompt beneath The empty-session landing screen previously centered the logo and tip vertically while the prompt stayed anchored at the bottom, leaving a large void between them. Now the logo sits top-left with the tip flush beneath it and the prompt directly below (Codex-style); a trailing flex spacer keeps the group at the top only while the session is empty, so the prompt returns to the bottom once the first message arrives. The tip renders through the shared home_bottom slot (also used by the still-reachable home route), so left alignment is opt-in via a new optional placement slot prop honored by the tips and which-key contributors; no prop keeps the centered layout. Co-Authored-By: Claude Fable 5 --- DIVERGENCES.md | 5 ++++- packages/plugin/src/tui.ts | 4 +++- .../tui/src/feature-plugins/home/tips.tsx | 20 +++++++++++++++---- .../src/feature-plugins/system/which-key.tsx | 14 +++++++++---- packages/tui/src/routes/session/index.tsx | 12 +++++++---- 5 files changed, 41 insertions(+), 14 deletions(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 3023444c5a..ccf99f91df 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` | Empty-session banner (Codex-style landing): when the session has no messages, the `Logo` wordmark sits top-left with the `home_bottom` tips slot beneath it (`placement="start"`), the `Prompt` renders directly below (a trailing flexGrow spacer after the prompt box keeps the group at the top; once messages exist the spacer hides and the scrollbox's flexGrow bottom-anchors the prompt again), 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; the banner + prompt hug the top-left like Codex instead of leaving a centered logo with a void above the prompt. | Keep our banner block, the trailing spacer, the `placeholders` prop, and the conditional `maxHeight`; 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 + {/* While the banner is shown the prompt hugs it at the top; + once messages exist the scrollbox's flexGrow takes over and + the prompt returns to the bottom. */} + + + From 79381dae5a293a81307550234986a6a028be17a7 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 13 Jul 2026 15:32:16 +0700 Subject: [PATCH 2/4] feat(tui): flow transcript with adaptive prompt position Instead of snapping back to the bottom once the first message arrives, the prompt now rides directly below the last message and only reaches the bottom when the conversation fills the screen, like Codex. The banner moves inside the scrollbox as the transcript's first entry for root sessions, so it scrolls away with history and stays visible above short conversations. The scrollbox holds its flex-derived height regardless of content, so its maxHeight is clamped to the content height via a signal sampled from scroll.scrollHeight on the renderer root's layout-changed event; an unconditional trailing spacer absorbs the leftover space. Co-Authored-By: Claude Fable 5 --- DIVERGENCES.md | 2 +- packages/tui/src/routes/session/index.tsx | 44 +++++++++++++---------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index ccf99f91df..e825460219 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -38,7 +38,7 @@ 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 (Codex-style landing): when the session has no messages, the `Logo` wordmark sits top-left with the `home_bottom` tips slot beneath it (`placement="start"`), the `Prompt` renders directly below (a trailing flexGrow spacer after the prompt box keeps the group at the top; once messages exist the spacer hides and the scrollbox's flexGrow bottom-anchors the prompt again), 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; the banner + prompt hug the top-left like Codex instead of leaving a centered logo with a void above the prompt. | Keep our banner block, the trailing spacer, the `placeholders` prop, and the conditional `maxHeight`; take upstream's surrounding changes. | +| `packages/tui/src/routes/session/index.tsx` | Codex-style 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 sampled from `scroll.scrollHeight` on the renderer root's `layout-changed` event — the scrollbox otherwise holds its flex-derived height) 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 Codex 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. | diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 17a1ef82d5..019bb867a3 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -337,6 +337,11 @@ export function Session() { let seeded = false let scroll: ScrollBoxRenderable + // Codex-style 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. scrollHeight is only current + // after a layout pass, so sample it from the root's layout-changed event. + const [transcriptHeight, setTranscriptHeight] = createSignal(1) let prompt: PromptRef | undefined const bind = (r: PromptRef | undefined) => { prompt = r @@ -349,6 +354,12 @@ export function Session() { const dialog = useDialog() const renderer = useRenderer() + onMount(() => { + const measure = () => scroll && setTranscriptHeight(Math.max(1, scroll.scrollHeight)) + 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,15 +1178,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={{ @@ -1192,12 +1194,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, Codex-style. */} + }> + + + + + {(message, index) => ( @@ -1333,12 +1342,9 @@ export function Session() { - {/* While the banner is shown the prompt hugs it at the top; - once messages exist the scrollbox's flexGrow takes over and - the prompt returns to the bottom. */} - - - + {/* Absorbs the leftover space below the prompt while the + clamped transcript is shorter than the screen. */} + From f170cdab0605cb05a0dbc490e6b31501c8dc4608 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 13 Jul 2026 15:50:51 +0700 Subject: [PATCH 3/4] fix(tui): apply transcript clamp in the same frame to stop flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While streaming into a transcript shorter than the screen, each content growth painted one frame with the previous clamp: the viewport was a row short, sticky-scroll shifted the transcript up, and the next frame shifted it back — visible flicker that stopped once the prompt reached the bottom (where the clamp no longer binds). layout-changed fires after yoga computes the layout but before it is applied and painted, and scroll.scrollHeight still holds last frame's value at that point. Read the fresh height off the content's yoga node instead and re-run calculateLayout so the frame paints with the updated clamp. Verified by sampling 400 rapid tmux captures during generation: the banner row never moves (12 shifted/blank frames before the fix). Co-Authored-By: Claude Fable 5 --- DIVERGENCES.md | 2 +- packages/tui/src/routes/session/index.tsx | 23 ++++++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index e825460219..a4562ca717 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -38,7 +38,7 @@ 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` | Codex-style 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 sampled from `scroll.scrollHeight` on the renderer root's `layout-changed` event — the scrollbox otherwise holds its flex-derived height) 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 Codex 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` | Codex-style 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 Codex 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. | diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 019bb867a3..62afb8a650 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -339,8 +339,7 @@ export function Session() { let scroll: ScrollBoxRenderable // Codex-style 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. scrollHeight is only current - // after a layout pass, so sample it from the root's layout-changed event. + // bottom once the content fills the screen. const [transcriptHeight, setTranscriptHeight] = createSignal(1) let prompt: PromptRef | undefined const bind = (r: PromptRef | undefined) => { @@ -355,7 +354,25 @@ export function Session() { const renderer = useRenderer() onMount(() => { - const measure = () => scroll && setTranscriptHeight(Math.max(1, scroll.scrollHeight)) + // 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)) }) From 4f51c8c540a33485eedfe1db118b36c68fecccb9 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 13 Jul 2026 16:57:54 +0700 Subject: [PATCH 4/4] docs: drop external product references from layout comments Co-Authored-By: Claude Fable 5 --- DIVERGENCES.md | 2 +- packages/tui/src/routes/session/index.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index a4562ca717..5330b52e9c 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -38,7 +38,7 @@ 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` | Codex-style 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 Codex 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` | 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. | diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 62afb8a650..87bf1be39e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -337,8 +337,8 @@ export function Session() { let seeded = false let scroll: ScrollBoxRenderable - // Codex-style flow layout: the transcript is clamped to its content height - // so the prompt rides directly below the last message and only reaches the + // 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 @@ -1217,7 +1217,7 @@ export function Session() { scrollAcceleration={scrollAcceleration()} > {/* Fork: the transcript opens with a home-like banner that - scrolls away with the history, Codex-style. */} + scrolls away with the history. */} }>