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
5 changes: 4 additions & 1 deletion DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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/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... "<suggestion>"` 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... "<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/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,<leader>q` → `ctrl+d,<leader>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, `<leader>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. |
Expand Down
4 changes: 3 additions & 1 deletion packages/plugin/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions packages/tui/src/feature-plugins/home/tips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -24,7 +30,13 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec
}))

return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<box
width="100%"
maxWidth={75}
alignItems={props.placement === "start" ? "flex-start" : "center"}
paddingTop={props.placement === "start" ? 0 : 3}
flexShrink={1}
>
<Show when={props.show}>
<Tips api={props.api} connected={props.connected} />
</Show>
Expand All @@ -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(() =>
Expand All @@ -45,7 +57,7 @@ const tui: TuiPlugin = async (api) => {
),
)
const show = createMemo(() => (!first() || !connected()) && !hidden())
return <View api={api} hidden={hidden()} show={show()} connected={connected()} />
return <View api={api} hidden={hidden()} show={show()} connected={connected()} placement={props.placement} />
},
},
})
Expand Down
14 changes: 10 additions & 4 deletions packages/tui/src/feature-plugins/system/which-key.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0}>
<box
width="100%"
maxWidth={75}
alignItems={props.placement === "start" ? "flex-start" : "center"}
paddingTop={1}
flexShrink={0}
>
<text fg={look().muted} wrapMode="none">
Show keyboard shortcuts with <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
</text>
Expand Down Expand Up @@ -578,8 +584,8 @@ const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 200,
slots: {
home_bottom() {
return <HomeHint api={api} />
home_bottom(_ctx, props) {
return <HomeHint api={api} placement={props.placement} />
},
app() {
return (
Expand Down
57 changes: 42 additions & 15 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -1167,17 +1195,6 @@ export function Session() {
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={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. */}
<Show when={messages().length === 0}>
<box flexGrow={1} minHeight={0} />
<box flexShrink={0} alignItems="center" gap={1}>
<Logo />
<pluginRuntime.Slot name="home_bottom" />
</box>
<box flexGrow={1} minHeight={0} />
</Show>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
Expand All @@ -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()}
>
<box height={1} />
{/* Fork: the transcript opens with a home-like banner that
scrolls away with the history. */}
<Show when={!session()?.parentID} fallback={<box height={1} />}>
<box flexShrink={0} alignItems="flex-start" paddingTop={1} paddingBottom={1} gap={1}>
<Logo />
<pluginRuntime.Slot name="home_bottom" placement="start" />
</box>
</Show>
<For each={messages()}>
{(message, index) => (
<Switch>
Expand Down Expand Up @@ -1335,6 +1359,9 @@ export function Session() {
</pluginRuntime.Slot>
</Show>
</box>
{/* Absorbs the leftover space below the prompt while the
clamped transcript is shorter than the screen. */}
<box flexGrow={1} minHeight={0} />
</Show>
<Toast />
</box>
Expand Down
Loading