mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-14 12:56:23 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1d3be7510 | ||
|
|
408a93915a |
@@ -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()}>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
Reference in New Issue
Block a user