Compare commits

...
Author SHA1 Message Date
Aiden Cline a1d3be7510 fix(tui): render the home prompt before plugins settle
Mount the production Home prompt as soon as the built-in theme is ready instead
of waiting for plugin reconciliation. Reserve the responsive logo and footer
geometry while plugins load, then fill that chrome without remounting or moving
the focused prompt. Session and continue launches remain gated.

Delay terminal-title output until after the first frame so it does not serialize
ahead of useful terminal output.

In compiled interleaved runs, prompt bytes improve from 215.5ms to 168ms and
first accepted input from 124ms to 76ms. Full chrome settles roughly 15ms later.
2026-09-11 19:32:13 -05:00
Aiden Cline 408a93915a fix(tui): defer named-theme palette detection
Named themes do not depend on terminal palette colors, but startup waited for
the shared system-theme palette request before marking the theme ready. Some
terminals answer palette support without answering every color query, adding
a roughly 300ms idle timeout before the useful frame.

Keep palette detection on the critical path for the explicit system theme.
For named themes, paint immediately and prepare the system palette after the
first frame so later theme switching remains available.

Compiled warm-server startup to Ask anything improves from 538ms to 215ms
for the default opencode theme. Explicit system theme startup remains flat
at 554ms to 546ms.
2026-09-11 18:35:10 -05:00
11 changed files with 442 additions and 33 deletions
+30 -7
View File
@@ -285,8 +285,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
)
renderer.once("destroy", () => shutdown.openUnsafe())
yield* Effect.tryPromise(async () => {
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
// The system theme needs terminal colors before its first paint. Named themes do not.
if (config.theme?.name === "system") void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
if (renderer.isDestroyed) return
@@ -484,7 +484,8 @@ function App(props: { pair?: DialogPairCredentials }) {
const toast = useToast()
const updater = useUpdateNotification()
const theme = useTheme()
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
const themes = useThemes()
const { mode, supports, setMode, locked, lock, unlock } = themes
const data = useData()
const location = useLocation()
const exit = useExit()
@@ -492,6 +493,24 @@ function App(props: { pair?: DialogPairCredentials }) {
const plugins = usePlugin()
const clipboard = useClipboard()
const terminalEnvironment = useTuiTerminalEnvironment()
let systemThemeTimeout: ReturnType<typeof setTimeout> | undefined
let terminalTitleTimeout: ReturnType<typeof setTimeout> | undefined
const [terminalTitleReady, setTerminalTitleReady] = createSignal(false)
const prepareSystemTheme = () => {
// The native writer can still be flushing the frame when FRAME fires. Keep OSC probes behind visible app output.
systemThemeTimeout = setTimeout(themes.prepareSystem, 50)
}
const finishFirstFrame = () => {
// Native terminal updates serialize behind frame output, so keep them from forcing an empty frame ahead of Home.
prepareSystemTheme()
terminalTitleTimeout = setTimeout(() => setTerminalTitleReady(true), 50)
}
onMount(() => renderer.once(CliRenderEvents.FRAME, finishFirstFrame))
onCleanup(() => {
renderer.off(CliRenderEvents.FRAME, finishFirstFrame)
if (systemThemeTimeout) clearTimeout(systemThemeTimeout)
if (terminalTitleTimeout) clearTimeout(terminalTitleTimeout)
})
createEffect(() => {
if (client.connection.status() !== "connected") return
if (route.data.type !== "session") return
@@ -605,6 +624,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const session = route.data.type === "session" ? data.session.get(route.data.sessionID) : undefined
if (session) active = { id: session.id, title: session.title }
if (!terminalTitleEnabled()) return
if (!terminalTitleReady()) return
if (route.data.type === "home") {
renderer.setTerminalTitle("OpenCode")
@@ -628,6 +648,7 @@ function App(props: { pair?: DialogPairCredentials }) {
})
const args = useArgs()
const promptFirstHome = () => route.data.type === "home" && !args.sessionID && !args.continue
const startupPrompt = args.prompt ? { text: args.prompt, files: [], agents: [], pasted: [] } : undefined
onMount(() => {
batch(() => {
@@ -1312,14 +1333,14 @@ function App(props: { pair?: DialogPairCredentials }) {
<SessionTabs orientation="vertical" width={tabsResize.size()} />
</Show>
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<Show when={promptFirstHome() || plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Show when={tabsVisible() && !tabsVertical()}>
<SessionTabs />
</Show>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
<Home ready={plugins.ready()} />
</Match>
<Match when={route.data.type === "session"}>
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
@@ -1340,7 +1361,9 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
</Switch>
</box>
<Slot path="app" />
<Show when={plugins.ready()}>
<Slot path="app" />
</Show>
</Show>
</box>
<Show when={verticalTabsVisible()}>
@@ -1350,7 +1373,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<Show when={devtools() && !(route.data.type === "plugin" && route.data.id === "opencode.stats")}>
<DevToolsBar />
</Show>
<Show when={!startup.skipInitialLoading}>
<Show when={!startup.skipInitialLoading && !promptFirstHome()}>
<StartupLoading ready={plugins.ready} />
</Show>
<Show when={showReconnecting()}>
+21 -1
View File
@@ -4,6 +4,26 @@ import { useTerminalDimensions } from "@opentui/solid"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import { go, logo } from "../logo"
import { stringWidth } from "../util/string-width"
export function logoSize(width: number, height: number) {
if (height < 12) return { width: 0, height: 0 }
if (width < 22)
return {
width: Math.max(...go.right.slice(1).map((line) => stringWidth(line))),
height: go.right.length - 1,
}
if (width < 44) {
const lines = [...logo.left.slice(1), ...logo.right]
return { width: Math.max(...lines.map((line) => stringWidth(line))), height: lines.length }
}
return {
width: Math.max(
...logo.left.map((line, index) => stringWidth(line) + 1 + stringWidth(logo.right[index] ?? "")),
),
height: logo.left.length,
}
}
export function Logo() {
const theme = useTheme()
@@ -50,7 +70,7 @@ export function Logo() {
}
return (
<box>
<box id="home-logo">
{dimensions().height < 12 ? null : dimensions().width < 22 ? (
<For each={go.right.slice(1)}>
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
+11 -2
View File
@@ -116,6 +116,7 @@ type Themes = {
unlock(): void
setMode(mode?: "dark" | "light", persist?: boolean): boolean
set(theme: string): boolean
prepareSystem(): void
onError(handler: ThemeErrorHandler): () => void
readonly ready: boolean
}
@@ -156,7 +157,8 @@ const themeContext = createSimpleContext({
draft.lock = lock
const active = config.theme?.name ?? "opencode"
draft.active = typeof active === "string" ? active : "opencode"
draft.ready = false
// Built-ins are complete synchronously; custom and system themes still gate their first paint on discovery.
draft.ready = active !== "system" && Boolean(draft.themes[active])
}),
)
@@ -184,7 +186,10 @@ const themeContext = createSimpleContext({
}
onMount(() => {
void Promise.allSettled([resolveSystemTheme(store.mode), syncCustomThemes()]).finally(() => {
void Promise.allSettled([
...(store.active === "system" ? [resolveSystemTheme(store.mode)] : []),
syncCustomThemes(),
]).finally(() => {
valuesV2()
setStore("ready", true)
})
@@ -343,6 +348,10 @@ const themeContext = createSimpleContext({
pin(requested, persist)
return true
},
prepareSystem() {
if (hasResolvedSystemTheme || systemRefreshRunning) return
refreshSystemTheme()
},
set(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme)
@@ -2,14 +2,7 @@ import { Plugin } from "@opencode/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { usePlugin } from "../../plugin/context"
export function homeFooterVisibility(width: number) {
return {
mcpCommand: width >= 64,
pluginCommand: width >= 80,
version: width >= 64,
}
}
import { homeFooterHeight, homeFooterVisibility } from "../../ui/layout"
function Mcp(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
@@ -76,13 +69,14 @@ function Plugins(props: { context: Plugin.Context }) {
function View(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
const height = createMemo(() => homeFooterHeight(dimensions().width, dimensions().height))
return (
<Show when={dimensions().height >= 12 && dimensions().width >= 44}>
<Show when={height() > 0}>
<box
width="100%"
paddingTop={dimensions().height < 16 ? 0 : 1}
paddingBottom={dimensions().height < 16 ? 0 : 1}
paddingTop={height() === 1 ? 0 : 1}
paddingBottom={height() === 1 ? 0 : 1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
+19 -6
View File
@@ -1,6 +1,6 @@
import { Prompt, type PromptRef } from "../component/prompt"
import { createEffect, createMemo, createSignal, onMount, Show, untrack } from "solid-js"
import { Logo } from "../component/logo"
import { Logo, logoSize } from "../component/logo"
import { useArgs } from "../context/args"
import { useRouteData } from "../context/route"
import { usePromptRef } from "../context/prompt"
@@ -15,6 +15,7 @@ import { useTheme } from "../context/theme"
import { useUpdateNotification } from "../context/update-notification"
import { useExit } from "../context/exit"
import { FadeInText } from "../component/fade-in-text"
import { homeFooterHeight } from "../ui/layout"
let once = false
const placeholder = {
@@ -22,7 +23,7 @@ const placeholder = {
shell: ["ls -la", "git status", "pwd"],
}
export function Home() {
export function Home(props: { ready: boolean }) {
const route = useRouteData("home")
const promptRef = usePromptRef()
const [ref, setRef] = createSignal<PromptRef | undefined>()
@@ -33,6 +34,7 @@ export function Home() {
const location = useLocation()
const dimensions = useTerminalDimensions()
const [logoWidth, setLogoWidth] = createSignal(0)
const reservedLogoSize = createMemo(() => logoSize(dimensions().width, dimensions().height))
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
const currentLocation = () => route.location ?? data.location.default()
const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? [])
@@ -88,12 +90,16 @@ export function Home() {
<box flexGrow={1} minHeight={0} />
<box height={3} minHeight={0} flexShrink={1} />
<box
width={reservedLogoSize().width}
height={reservedLogoSize().height}
flexShrink={0}
onSizeChange={function () {
setLogoWidth(this.width)
}}
>
<Logo />
<Show when={props.ready}>
<Logo />
</Show>
</box>
<box height={1} flexShrink={0} />
<UpdateNotification width={logoWidth()} />
@@ -102,9 +108,16 @@ export function Home() {
</box>
<box flexGrow={1} minHeight={0} />
</box>
<box width="100%" flexShrink={0}>
<Slot path="home.footer" />
</box>
<Show
when={props.ready}
fallback={
<box width="100%" height={homeFooterHeight(dimensions().width, dimensions().height)} flexShrink={0} />
}
>
<box width="100%" flexShrink={0}>
<Slot path="home.footer" />
</box>
</Show>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
+13
View File
@@ -20,3 +20,16 @@ export function clampSessionPaneWidth(width: number, total: number) {
// Preserve the equal split when there is not enough room for both pane minima.
return Math.max(Math.min(24, half), Math.min(width, Math.max(half, total - SESSION_CONTENT_MIN_WIDTH)))
}
export function homeFooterHeight(width: number, height: number) {
if (height < 12 || width < 44) return 0
return height < 16 ? 1 : 3
}
export function homeFooterVisibility(width: number) {
return {
mcpCommand: width >= 64,
pluginCommand: width >= 80,
version: width >= 64,
}
}
+110 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { EmbeddedTerminalRenderable } from "@opentui/core"
import { EmbeddedTerminalRenderable, TextareaRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
@@ -430,6 +430,115 @@ test("session title generated while an untitled session is loading remains visib
}
})
test.each([
{ name: "wide", width: 100, height: 30 },
{ name: "narrow", width: 40, height: 12 },
])("prompt-first Home preserves its production prompt through plugin readiness ($name)", async (size) => {
await using state = await tmpdir()
const requested = Promise.withResolvers<void>()
const plugins = Promise.withResolvers<{ directory: string }>()
await using setup = await createAppFixture({
width: size.width,
height: size.height,
state: state.path,
config: { animations: false, tabs: { enabled: false }, plugins: ["fixture"] },
packages: {
prepare: async () => {
requested.resolve()
return plugins.promise
},
},
})
try {
await requested.promise
await setup.ready
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof TextareaRenderable)
const prompt = setup.renderer.currentFocusedEditor!
await setup.mockInput.typeText("typed before plugins")
await setup.waitForFrame((frame) => frame.includes("typed before plugins"))
const geometry = { x: prompt.x, y: prompt.y, width: prompt.width, height: prompt.height }
expect(setup.renderer.root.findDescendantById("home-logo")).toBeUndefined()
plugins.resolve({ directory: "" })
await setup.waitFor(() => setup.renderer.root.findDescendantById("home-logo") !== undefined)
await setup.renderOnce()
expect(setup.renderer.currentFocusedEditor).toBe(prompt)
expect(prompt.plainText).toBe("typed before plugins")
expect({ x: prompt.x, y: prompt.y, width: prompt.width, height: prompt.height }).toEqual(geometry)
await setup.mockInput.typeText(" and remains usable")
await setup.waitFor(() => prompt.plainText === "typed before plugins and remains usable")
expect(setup.renderer.currentFocusedEditor).toBe(prompt)
} finally {
const prompt = setup.renderer.currentFocusedEditor
if (prompt instanceof TextareaRenderable && prompt.plainText) {
setup.mockInput.pressKey("c", { ctrl: true })
await setup.waitFor(() => prompt.plainText === "")
}
plugins.resolve({ directory: "" })
}
})
test.each([
{ name: "session", args: { sessionID: "ses_startup_gate" } },
{ name: "continue", args: { continue: true } },
])("initial $name routing stays gated while plugins load", async ({ args }) => {
await using state = await tmpdir()
const requested = Promise.withResolvers<void>()
const plugins = Promise.withResolvers<{ directory: string }>()
const session = {
id: "ses_startup_gate",
title: "Startup gate fixture",
projectID: "proj_test",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
}
await using setup = await createAppFixture({
state: state.path,
args,
config: { animations: false, tabs: { enabled: false }, plugins: ["fixture"] },
packages: {
prepare: async () => {
requested.resolve()
return plugins.promise
},
},
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: [], cursor: {} })
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
return json({ data: [] })
return undefined
},
})
try {
await requested.promise
await setup.ready
await setup.renderOnce()
expect(setup.renderer.currentFocusedEditor).toBeNull()
expect(setup.renderer.root.findDescendantById("home-logo")).toBeUndefined()
expect(setup.captureCharFrame()).not.toContain("Ask anything")
plugins.resolve({ directory: "" })
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof TextareaRenderable)
expect(setup.renderer.root.findDescendantById("home-logo")).toBeUndefined()
await setup.mockInput.typeText("session prompt ready")
await setup.waitForFrame((frame) => frame.includes("session prompt ready"))
} finally {
const prompt = setup.renderer.currentFocusedEditor
if (prompt instanceof TextareaRenderable && prompt.plainText) {
setup.mockInput.pressKey("c", { ctrl: true })
await setup.waitFor(() => prompt.plainText === "")
}
plugins.resolve({ directory: "" })
}
})
test("vertical session tabs collapse to a compact rail with the terminal", async () => {
await using state = await tmpdir()
await Bun.write(path.join(state.path, "test", "tui", "layout.json"), JSON.stringify({ verticalTabsWidth: 42 }))
+212 -3
View File
@@ -1,13 +1,21 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { render, testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { CliRenderEvents, RGBA, type TerminalColors } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { createSignal } from "solid-js"
import { DEFAULT_THEME, selectTheme } from "@opencode/theme/tui"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { DEFAULT_THEMES } from "../../../src/theme"
import { ConfigProvider } from "../../../src/config"
import { ThemeContextProvider, ThemeProvider, type ThemeError, useTheme, useThemes } from "../../../src/context/theme"
import {
ThemeContextProvider,
ThemeProvider,
type ThemeError,
type ThemeSource,
useTheme,
useThemes,
} from "../../../src/context/theme"
async function wait(fn: () => boolean) {
const started = Date.now()
@@ -121,6 +129,54 @@ test.each([
}
})
test("mounts built-in theme contents while custom theme discovery continues", async () => {
const discovery = Promise.withResolvers<Record<string, unknown>>()
const discovered = Promise.withResolvers<void>()
let mounts = 0
let themes: ReturnType<typeof useThemes> | undefined
function Probe() {
mounts++
themes = useThemes()
return <text>ready</text>
}
const app = await testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig({})}>
<ThemeProvider
mode="dark"
source={{
discover: async () => {
const value = await discovery.promise
discovered.resolve()
return value
},
}}
>
<Probe />
</ThemeProvider>
</ConfigProvider>
),
{ width: 20, height: 2 },
)
app.renderer.start()
try {
expect(mounts).toBe(1)
expect(themes?.ready).toBeTrue()
expect(themes?.selected).toBe("opencode")
discovery.resolve({})
await discovered.promise
await app.renderOnce()
expect(mounts).toBe(1)
expect(themes?.selected).toBe("opencode")
} finally {
discovery.resolve({})
app.renderer.destroy()
}
})
test("contextual hooks resolve overrides and fall back to a standalone theme's base view", async () => {
const standalone = {
version: 2,
@@ -222,3 +278,156 @@ test.each(["dark", "light"] as const)(
}
},
)
test("does not request terminal colors before a named theme is usable", async () => {
await using app = await renderPaletteTheme({ name: "opencode" })
expect(app.paletteCalls()).toBe(0)
expect(app.themes.selected).toBe("opencode")
app.themes.prepareSystem()
await wait(() => app.paletteCalls() === 1)
})
test("resolves the system theme from the terminal palette", async () => {
await using app = await renderPaletteTheme({ name: "system", colors: terminalColors("#101010") })
expect(app.paletteCalls()).toBe(1)
expect(app.themes.selected).toBe("system")
expect(app.themes.mode()).toBe("dark")
})
test("refreshes the system palette after a terminal theme notification", async () => {
await using app = await renderPaletteTheme({ name: "system", colors: terminalColors("#101010") })
app.notify("\x1b[?997;2n")
await wait(() => app.paletteCalls() === 2)
expect(app.themes.mode()).toBe("dark")
})
test.each([
["dark", "#fefefe"],
["light", "#010101"],
] as const)("keeps an explicit %s mode when the terminal reports the opposite mode", async (mode, background) => {
await using app = await renderPaletteTheme({ name: "system", mode, colors: terminalColors(background) })
expect(app.themes.mode()).toBe(mode)
})
test("removes theme handlers and pending refreshes on cleanup", async () => {
let refresh = () => {}
let unsubscribed = false
const app = await renderPaletteTheme({
name: "opencode",
source: {
discover: async () => ({}),
subscribeRefresh(next) {
refresh = next
return () => {
unsubscribed = true
}
},
},
})
expect(app.renderer.listenerCount(CliRenderEvents.THEME_MODE)).toBe(1)
refresh()
app.renderer.destroy()
await Bun.sleep(1100)
expect(app.paletteCalls()).toBe(0)
expect(app.renderer.listenerCount(CliRenderEvents.THEME_MODE)).toBe(0)
expect(app.removedNotificationHandler()).toBeTrue()
expect(unsubscribed).toBeTrue()
})
function terminalColors(background: string): TerminalColors {
return {
palette: [
"#000000",
"#cc0000",
"#00cc00",
"#cccc00",
"#0000cc",
"#cc00cc",
"#00cccc",
"#cccccc",
"#555555",
"#ff0000",
"#00ff00",
"#ffff00",
"#0000ff",
"#ff00ff",
"#00ffff",
"#ffffff",
],
defaultForeground: "#eeeeee",
defaultBackground: background,
cursorColor: null,
mouseForeground: null,
mouseBackground: null,
tekForeground: null,
tekBackground: null,
highlightBackground: null,
highlightForeground: null,
}
}
async function renderPaletteTheme(input: {
name: "opencode" | "system"
mode?: "dark" | "light"
colors?: TerminalColors
source?: ThemeSource
}) {
const setup = await createTestRenderer({ width: 20, height: 2 })
const colors = input.colors ?? terminalColors("#101010")
let paletteCalls = 0
let notificationHandler: ((sequence: string) => boolean) | undefined
let removedNotificationHandler = false
const prependInputHandler = setup.renderer.prependInputHandler.bind(setup.renderer)
const removeInputHandler = setup.renderer.removeInputHandler.bind(setup.renderer)
setup.renderer.getPalette = async () => {
paletteCalls++
return colors
}
setup.renderer.prependInputHandler = (handler) => {
notificationHandler = handler
prependInputHandler(handler)
}
setup.renderer.removeInputHandler = (handler) => {
if (handler === notificationHandler) removedNotificationHandler = true
removeInputHandler(handler)
}
let themes: ReturnType<typeof useThemes> | undefined
function Probe() {
themes = useThemes()
return <text>{themes.selected}</text>
}
await render(
() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: input.name, mode: input.mode } })}>
<ThemeProvider mode="dark" source={input.source ?? { discover: async () => ({}) }}>
<Probe />
</ThemeProvider>
</ConfigProvider>
),
setup.renderer,
)
setup.renderer.start()
await wait(() => themes?.ready === true)
if (!themes) throw new Error("Theme provider is not mounted")
return {
renderer: setup.renderer,
themes,
paletteCalls: () => paletteCalls,
notify(sequence: string) {
if (!notificationHandler) throw new Error("Theme notification handler is not registered")
notificationHandler(sequence)
},
removedNotificationHandler: () => removedNotificationHandler,
async [Symbol.asyncDispose]() {
setup.renderer.destroy()
},
}
}
+10
View File
@@ -0,0 +1,10 @@
import { expect, test } from "bun:test"
import { logoSize } from "../../src/component/logo"
test("logo reserve follows the production responsive breakpoints", () => {
expect(logoSize(100, 11)).toEqual({ width: 0, height: 0 })
expect(logoSize(21, 12)).toEqual({ width: 4, height: 3 })
expect(logoSize(22, 12)).toEqual({ width: 19, height: 7 })
expect(logoSize(43, 12)).toEqual({ width: 19, height: 7 })
expect(logoSize(44, 12)).toEqual({ width: 39, height: 4 })
})
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { homeFooterVisibility } from "../../src/feature-plugins/home/footer"
import { homeFooterHeight, homeFooterVisibility } from "../../src/ui/layout"
describe("home footer visibility", () => {
test("keeps failure labels readable at the minimum supported width", () => {
@@ -11,3 +11,11 @@ describe("home footer visibility", () => {
expect(homeFooterVisibility(80)).toEqual({ mcpCommand: true, pluginCommand: true, version: true })
})
})
test("home footer height matches its responsive visibility and padding", () => {
expect(homeFooterHeight(43, 30)).toBe(0)
expect(homeFooterHeight(44, 11)).toBe(0)
expect(homeFooterHeight(44, 12)).toBe(1)
expect(homeFooterHeight(44, 15)).toBe(1)
expect(homeFooterHeight(44, 16)).toBe(3)
})
+2 -1
View File
@@ -13,6 +13,7 @@ export async function createAppFixture(
state?: string
config?: Config.Info
args?: TuiInput["args"]
packages?: TuiInput["packages"]
fetch?: FetchHandler
} = {},
) {
@@ -33,7 +34,7 @@ export async function createAppFixture(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
packages: input.packages ?? { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: input.args ?? {},
log: () => {},