diff --git a/packages/tui/src/component/welcome-panel-utils.ts b/packages/tui/src/component/welcome-panel-utils.ts index 853abc2e6..ee2531cc8 100644 --- a/packages/tui/src/component/welcome-panel-utils.ts +++ b/packages/tui/src/component/welcome-panel-utils.ts @@ -5,38 +5,63 @@ // bordered box, so on a typical terminal it eats ~40% of the screen with no way // to shrink it (issue #1067). We scale it down by the space the panel ACTUALLY // has on both axes: -// - width: the terminal minus the caller's padding and any sibling sidebar. +// - width: the terminal minus the caller's padding and any sibling sidebar +// that consumes layout width. // - height: the terminal minus the fixed chrome that always shares the column -// with the panel (top spacer + prompt + footer), so a big panel is +// with the panel (the per-route reserves below), so a big panel is // chosen only when it won't crowd the prompt off a short terminal. // `medium` (no wordmark) is the common case; `full` only when there's real room. // -// Naming: these are the MINIMUMS a tier requires, matched with strict `<` -// (`width < FULL_MIN_WIDTH` → not full). MEDIUM_MIN_* is the floor for medium -// (below → compact); FULL_MIN_* is the floor for full (below → medium). All in -// terms of AVAILABLE (usable) size, not the raw terminal. +// Route arithmetic lives here (homeAvailable / sessionAvailable) so the routes +// and the tests share ONE definition — a test that maps a terminal size to a +// variant then exercises the real call-site math, not a copy of it. export type WelcomePanelVariant = "full" | "medium" | "compact" -/** - * Rows the panel must leave for the always-present chrome below/around it (the - * prompt, the footer, and the home top spacer). Callers subtract this from the - * terminal height to get the panel's usable height. An estimate — the prompt can - * grow with multi-line input, but at rest this is the fixed cost. - */ -export const PANEL_VERTICAL_RESERVE = 8 +// --- Chrome reserves (rows the panel must leave for always-present siblings) --- +// +// Prompt tree at rest (component/prompt/index.tsx): top rule 1 + inner paddingTop +// 1 + textarea 1 + separator 1 + agent/model meta row 1 + idle hint row 1 = 6. +// Measured against the rendered tree, not estimated — the earlier "~4" undercounted +// it (the meta and hint rows are always in flow). +const PROMPT_REST_HEIGHT = 6 -/** Minimum usable size for the medium panel; below either → compact (one line). */ +// home (routes/home.tsx): top spacer 2 (``) + prompt wrapper +// paddingTop 1 + prompt 6 + home_bottom slot 3 (feature-plugins/home/tips.tsx +// paddingTop, rendered unconditionally — flexbox shrinks content, not padding) + +// footer 3 (feature-plugins/home/footer.tsx) = 15. +export const HOME_VERTICAL_RESERVE = 2 + 1 + PROMPT_REST_HEIGHT + 3 + 3 +// session (routes/session/index.tsx): two column gaps 2 + paddingBottom 1 + +// prompt 6 = 9. No top spacer and no footer share this column. +export const SESSION_VERTICAL_RESERVE = 2 + 1 + PROMPT_REST_HEIGHT + +// Columns the home slot spends on its own left/right padding (2 + 2). Session +// subtracts the same via sessionAvailable(); both routes import this constant so +// the value has a single source of truth. +export const PANEL_HORIZONTAL_PADDING = 4 +// Width the session sidebar occupies WHEN it consumes layout width (i.e. it is +// rendered in-flow, not as an overlay). Shared with the route + tests so they +// can't drift — the drift that produced #1067. +export const SIDEBAR_WIDTH = 42 + +// --- Thresholds --- +// MEDIUM_MIN_* is a real FIT requirement: below it the medium panel (~8 rows / +// ~a title + one/two-line description) would not fit, so drop to the one-line +// compact. FULL_MIN_* is a product BREAKPOINT, not a fit minimum: the full panel +// is only ~13 rows, but we require far more available space so the branded +// wordmark appears only on a genuinely large terminal and never dominates a +// small one (the #1067 ask). All in AVAILABLE (usable) terms, not the raw +// terminal, matched with strict `<`. export const MEDIUM_MIN_WIDTH = 60 -export const MEDIUM_MIN_HEIGHT = 16 -/** Minimum usable size for the full wordmark panel; below either → medium. */ +export const MEDIUM_MIN_HEIGHT = 8 export const FULL_MIN_WIDTH = 110 -export const FULL_MIN_HEIGHT = 36 +// ~45-row home terminal / ~39-row session terminal after the reserves above. +export const FULL_MIN_HEIGHT = 30 /** * Choose the WelcomePanel layout from the panel's AVAILABLE size — width already - * minus padding/sidebar, height already minus PANEL_VERTICAL_RESERVE. Not the - * raw terminal (that's the #1067 bug: a sidebar-narrowed column, or a short + * minus padding/sidebar, height already minus the route's vertical reserve. Not + * the raw terminal (that's the #1067 bug: a sidebar-narrowed column, or a short * terminal, would still pick `full`). */ export function welcomePanelVariant(width: number, height: number): WelcomePanelVariant { @@ -44,3 +69,32 @@ export function welcomePanelVariant(width: number, height: number): WelcomePanel if (width < FULL_MIN_WIDTH || height < FULL_MIN_HEIGHT) return "medium" return "full" } + +/** Available panel size on the home route for a given terminal size. */ +export function homeAvailable(terminalWidth: number, terminalHeight: number): { width: number; height: number } { + return { + width: terminalWidth - PANEL_HORIZONTAL_PADDING, + height: terminalHeight - HOME_VERTICAL_RESERVE, + } +} + +/** + * Available panel size on the session route. Subtracts SIDEBAR_WIDTH whenever the + * sidebar is open (`sidebarVisible`) — the panel shares the same content-column + * basis as the messages (session's `contentWidth`), so the two stay aligned. + * + * On narrow terminals the sidebar renders as a dimmed full-area overlay rather + * than in-flow; the content column still narrows uniformly (panel + messages) + * and both restore to full width when it closes, so we deliberately size to the + * narrowed column rather than the transient obscured width. + */ +export function sessionAvailable( + terminalWidth: number, + terminalHeight: number, + sidebarVisible: boolean, +): { width: number; height: number } { + return { + width: terminalWidth - (sidebarVisible ? SIDEBAR_WIDTH : 0) - PANEL_HORIZONTAL_PADDING, + height: terminalHeight - SESSION_VERTICAL_RESERVE, + } +} diff --git a/packages/tui/src/component/welcome-panel.tsx b/packages/tui/src/component/welcome-panel.tsx index b8c16eff9..e42982d70 100644 --- a/packages/tui/src/component/welcome-panel.tsx +++ b/packages/tui/src/component/welcome-panel.tsx @@ -1,6 +1,5 @@ import { Match, Show, Switch, createMemo } from "solid-js" import { TextAttributes } from "@opentui/core" -import { useTerminalDimensions } from "@opentui/solid" import { useTheme } from "../context/theme" import { Logo } from "./logo" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -16,27 +15,31 @@ const CONNECT_CTA = "Connect your AI model to start." // blank its top rows. // // Responsive (issue #1067): the full two-column boot box is a ~13-row bordered -// box that ate ~40% of the screen. It now scales down by AVAILABLE size, -// following the repo's breakpoint idiom (createMemo over useTerminalDimensions, -// cf. routes/session/permission.tsx:450, component/upgrade-indicator.tsx:14): +// box that ate ~40% of the screen. It now scales down by AVAILABLE size — the +// caller measures the terminal (useTerminalDimensions) and passes what the panel +// actually gets, following the repo's breakpoint idiom (a createMemo over the +// reactive dimensions, cf. routes/session/permission.tsx:450, +// component/upgrade-indicator.tsx:14): // full — wordmark + full description (large windows only) -// medium — title + one condensed line, no wordmark (the common case) +// medium — title + a condensed description (one line on a wide terminal, two at +// medium's narrow end), no wordmark (the common case) // compact — a short line; the border title already carries the version // // `availableWidth` / `availableHeight` are the space the panel actually gets, not // the whole terminal — the caller subtracts its padding, any sibling sidebar -// (session's contentWidth), and the fixed prompt/footer chrome -// (PANEL_VERTICAL_RESERVE). Using the raw terminal would keep `full` selected in -// a sidebar-narrowed column or a short window and swell the panel back up — the -// bug #1067 is about. Both fall back to the terminal dimension when omitted. -export function WelcomePanel(props: { availableWidth?: number; availableHeight?: number }) { +// (session's contentWidth), and the route's vertical reserve (HOME/SESSION_ +// VERTICAL_RESERVE). Using the raw terminal would keep `full` selected in a +// sidebar-narrowed column or a short window and swell the panel back up — the bug +// #1067 is about. Both props are REQUIRED: a call site that forgot one would +// silently get the pre-fix raw-terminal behavior, so the type system guards it +// (there's no in-repo render test of the call sites). +export function WelcomePanel(props: { availableWidth: number; availableHeight: number }) { const { theme } = useTheme() const ready = useReady() - const dimensions = useTerminalDimensions() - const variant = createMemo(() => - welcomePanelVariant(props.availableWidth ?? dimensions().width, props.availableHeight ?? dimensions().height), - ) + // props are reactive getters, so reading them inside the memo tracks — the + // variant recomputes when the caller's dimensions/sidebar change. + const variant = createMemo(() => welcomePanelVariant(props.availableWidth, props.availableHeight)) const title = InstallationVersion === "local" ? " Altimate Code " : ` Altimate Code v${InstallationVersion} ` diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index caa6d10e8..1f15e13a8 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -19,7 +19,7 @@ import { useTheme } from "../context/theme" // one-line "Get started: /connect ... /discover ..." hint below, which duplicated the // same guidance the panel's "Tips for getting started" section now covers. import { WelcomePanel } from "../component/welcome-panel" -import { PANEL_VERTICAL_RESERVE } from "../component/welcome-panel-utils" +import { homeAvailable } from "../component/welcome-panel-utils" // altimate_change end let once = false @@ -68,6 +68,11 @@ export function Home() { if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7)) return configured ?? 75 }) + // altimate_change start — WelcomePanel responsive sizing (#1067): the panel's + // available space = terminal minus this route's padding + vertical reserve. + // Shared arithmetic in welcome-panel-utils so the tests exercise it. + const panelAvailable = createMemo(() => homeAvailable(dimensions().width, dimensions().height)) + // altimate_change end let sent = false onMount(() => { @@ -109,13 +114,10 @@ export function Home() { - {/* Size to the panel's real space, not the whole terminal (#1067): - -4 for this column's paddingLeft/Right; -PANEL_VERTICAL_RESERVE for - the top spacer + prompt + footer that share the height. */} - + {/* Size to the panel's real space, not the whole terminal (#1067). + homeAvailable() subtracts this column's padding and the top + spacer + prompt + home_bottom + footer reserve. */} + diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 0521a6248..57074debf 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -26,7 +26,7 @@ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { Spinner } from "../../component/spinner" // altimate_change — shared boot box at the top of the session scrollback import { WelcomePanel } from "../../component/welcome-panel" -import { PANEL_VERTICAL_RESERVE } from "../../component/welcome-panel-utils" +import { PANEL_HORIZONTAL_PADDING, SIDEBAR_WIDTH, sessionAvailable } from "../../component/welcome-panel-utils" import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme" import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" @@ -272,7 +272,15 @@ export function Session() { return false }) const showTimestamps = createMemo(() => timestamps() === "show") - const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) + // altimate_change start — WelcomePanel responsive sizing (#1067). panelAvailable is the single + // source of the panel's usable size (via welcome-panel-utils, unit-tested); it narrows with the + // sidebar whenever open — including the dimmed full-area overlay on narrow terminals — so the + // panel and the messages stay aligned and both restore to full width when it closes. contentWidth + // (the shared content-column width) derives from it, so the width math has one definition and the + // two cannot structurally drift. + const panelAvailable = createMemo(() => sessionAvailable(dimensions().width, dimensions().height, sidebarVisible())) + const contentWidth = createMemo(() => panelAvailable().width) + // altimate_change end const providers = createMemo(() => Model.index(sync.data.provider)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) @@ -1187,13 +1195,10 @@ export function Session() { /discover) starts a session. Outside the scrollbox: the bordered panel does not paint reliably inside the scroll viewport. */} - {/* Size to the panel's real space, not the terminal (#1067): - contentWidth already subtracts the sidebar + padding; - -PANEL_VERTICAL_RESERVE leaves room for the prompt + footer. */} - + {/* Size to the panel's real space, not the terminal (#1067). + panelAvailable() subtracts the in-flow sidebar + padding and + reserves the prompt row (see the memo above). */} + {/* altimate_change end */} { + const a = homeAvailable(w, h) + return welcomePanelVariant(a.width, a.height) +} +const session = (w: number, h: number, sidebarVisible: boolean) => { + const a = sessionAvailable(w, h, sidebarVisible) + return welcomePanelVariant(a.width, a.height) +} + +test("reserves stay pinned to the counted chrome", () => { + // If someone changes the reserve to a wrong literal, this fails. The values are + // derived sums of the documented per-route chrome (see welcome-panel-utils.ts). + expect(HOME_VERTICAL_RESERVE).toBe(15) + expect(SESSION_VERTICAL_RESERVE).toBe(9) +}) + test("full requires BOTH width and height to clear the full floor", () => { expect(welcomePanelVariant(WIDE, TALL)).toBe("full") expect(welcomePanelVariant(FULL_MIN_WIDTH, FULL_MIN_HEIGHT)).toBe("full") // exactly at the floor @@ -42,23 +65,58 @@ test("compact→medium boundary is exact (at the floor is medium)", () => { }) test("everyday terminals get medium, not the oversized wordmark", () => { - // Inputs are AVAILABLE size (terminal minus padding/sidebar on width, minus - // PANEL_VERTICAL_RESERVE on height). A 106x31 terminal → ~(102, 23): - expect(welcomePanelVariant(102, 23)).toBe("medium") - // 80x24 terminal → ~(76, 16) — medium exactly at the height floor: - expect(welcomePanelVariant(76, 16)).toBe("medium") - // #1067 session case: a 130-col terminal with the 42-col sidebar leaves ~84 - // usable cols → medium (was wrongly full when it used the whole terminal width). - expect(welcomePanelVariant(130 - 42 - 4, 50 - PANEL_VERTICAL_RESERVE)).toBe("medium") + expect(home(106, 31)).toBe("medium") + expect(home(80, 24)).toBe("medium") + // #1067 session case: a 130-col terminal with the in-flow 42-col sidebar leaves + // ~84 usable cols → medium (was wrongly full when it used the whole terminal). + expect(session(130, 50, true)).toBe("medium") +}) + +test("the classic 80x24 is medium on both routes (a real fit, not a fake margin)", () => { + // 80x24 → home available (76 × 9), session available (76 × 15). Both clear the + // medium floor (60 × 8) — the medium panel is ~8 rows, so this actually fits. + expect(home(80, 24)).toBe("medium") + expect(session(80, 24, false)).toBe("medium") +}) + +test("full engages on a large window; ~one row below the floor stays medium", () => { + // full needs available height ≥ FULL_MIN_HEIGHT(30); home reserves 15, so the + // terminal must be ≥ 45 rows. + expect(home(120, 45)).toBe("full") + expect(home(120, 44)).toBe("medium") +}) + +test("toggling the in-flow session sidebar flips the panel full → medium on a wide window (#1067)", () => { + // The exact regression #1067 reports: on a wide window the sidebar is in-flow, + // and opening it must shrink the panel out of `full` (no longer ≥110 usable cols). + expect(session(150, 50, false)).toBe("full") + expect(session(150, 50, true)).toBe("medium") +}) + +test("the content column narrows whenever the sidebar is open, incl. the overlay (aligned with messages)", () => { + // On a ≤120-col terminal the sidebar is a dimmed full-area overlay, but the + // content column (messages + panel) still narrows uniformly so they stay + // aligned and both restore when it closes — sizing to the narrowed column, not + // the transient obscured width. 100 cols: open → 54 usable → compact; closed → + // 96 → medium. (Matches session's contentWidth basis; deliberate, per review.) + expect(session(100, 50, true)).toBe("compact") + expect(session(100, 50, false)).toBe("medium") +}) + +test("a short terminal drops to compact once the route's chrome is reserved (#1067 height)", () => { + // 120x22: wide, but too few usable rows after the home chrome → compact, where + // the raw terminal height (22) would have picked medium. + expect(home(120, 22)).toBe("compact") }) -test("a short terminal drops to compact once prompt/footer chrome is reserved (#1067 height)", () => { - // 120x22: wide, but only ~14 usable rows after the ~8-row chrome → compact, - // where the raw terminal height (22) would have picked medium. - expect(welcomePanelVariant(120 - 4, 22 - PANEL_VERTICAL_RESERVE)).toBe("compact") +test("negative available height (a tiny terminal) collapses to compact, never throws", () => { + // 80x5 → home available height 5 - 15 = -10; must resolve, not crash. + expect(home(80, 5)).toBe("compact") + expect(session(80, 5, false)).toBe("compact") }) test("degenerate sizes collapse to compact", () => { expect(welcomePanelVariant(0, 0)).toBe("compact") expect(welcomePanelVariant(1, 1)).toBe("compact") + expect(welcomePanelVariant(-5, -5)).toBe("compact") }) diff --git a/packages/tui/test/component/welcome-panel.test.tsx b/packages/tui/test/component/welcome-panel.test.tsx new file mode 100644 index 000000000..390925532 --- /dev/null +++ b/packages/tui/test/component/welcome-panel.test.tsx @@ -0,0 +1,103 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { resetSetupComplete } from "../../src/component/altimate-onboarding" +import { WelcomePanel } from "../../src/component/welcome-panel" +import { TuiConfigProvider } from "../../src/config" +import { FULL_MIN_HEIGHT, FULL_MIN_WIDTH, MEDIUM_MIN_WIDTH } from "../../src/component/welcome-panel-utils" +import { ArgsProvider } from "../../src/context/args" +import { ExitProvider } from "../../src/context/exit" +import { KVProvider } from "../../src/context/kv" +import { ProjectProvider } from "../../src/context/project" +import { RouteProvider } from "../../src/context/route" +import { SDKProvider } from "../../src/context/sdk" +import { SyncProvider } from "../../src/context/sync" +import { ThemeProvider } from "../../src/context/theme" +import { ToastProvider } from "../../src/ui/toast" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" +import { createEventSource, createFetch, directory } from "../fixture/tui-sdk" + +// Render the REAL WelcomePanel through the routes' variant sizing and assert the +// rendered content per variant — the piece the pure welcomePanelVariant unit test +// can't cover (that the .tsx Switch actually renders the right box for a variant). +// Sizing is driven by the availableWidth/availableHeight props exactly as the +// routes pass them; the canvas is generous so content is captured without clipping. +async function renderPanel(availableWidth: number, availableHeight: number) { + resetSetupComplete() // ready() = false → deterministic (unconnected: shows the connect CTA) + const calls = createFetch() + const source = createEventSource() + const app = await testRender( + () => ( + + {}}> + + + + + + + + + + + + + + + + + + + + + + ), + { width: 160, height: 40 }, + ) + await app.renderOnce() + await new Promise((resolve) => setTimeout(resolve, 25)) + await app.renderOnce() + let frame = app.captureCharFrame() + for (let attempt = 0; attempt < 5 && frame.trim().length === 0; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 25)) + await app.renderOnce() + frame = app.captureCharFrame() + } + return { app, frame } +} + +// Own the renderer per test and destroy it in the same test's finally (plus reset +// onboarding state), so nothing leaks between tests or races under parallel bun test. +async function withPanel(width: number, height: number, check: (frame: string) => void) { + const { app, frame } = await renderPanel(width, height) + try { + check(frame) + } finally { + app.renderer.destroy() + resetSetupComplete() + } +} + +test("full variant renders the wordmark + what-is section at a large available size", async () => { + await withPanel(FULL_MIN_WIDTH, FULL_MIN_HEIGHT, (frame) => { + // "What is Altimate Code" is unique to the full two-column box. + expect(frame).toContain("What is Altimate Code") + }) +}) + +test("medium variant renders the condensed line (no what-is section) below the full width floor", async () => { + await withPanel(FULL_MIN_WIDTH - 1, FULL_MIN_HEIGHT, (frame) => { + expect(frame).toContain("Gives your AI real context") + expect(frame).not.toContain("What is Altimate Code") + }) +}) + +test("compact variant renders a single line below the medium width floor", async () => { + await withPanel(MEDIUM_MIN_WIDTH - 1, FULL_MIN_HEIGHT, (frame) => { + // Unconnected → the connect CTA; neither the medium nor full body is present. + expect(frame).toContain("Connect your AI model to start") + expect(frame).not.toContain("Gives your AI real context") + expect(frame).not.toContain("What is Altimate Code") + }) +})