diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 57f372ef709a..be27ffc4b7e3 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -24,7 +24,14 @@ import { Show, on, } from "solid-js" -import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime" +import { + TUI_STARTUP_STAGES, + TuiPathsProvider, + TuiStartupProvider, + TuiTerminalEnvironmentProvider, + useTuiStartup, + type TuiStartupStage, +} from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" import { DialogProvider as DialogProviderList } from "./component/dialog-provider" import { ErrorComponent } from "./component/error-component" @@ -235,120 +242,148 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { ) renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void)) const pluginRuntime = createPluginRuntime() + const [startupMode, setStartupMode] = createSignal<"dark" | "light">() + const [startupStage, setStartupStage] = createSignal("terminal") + // Concurrent or fast-boot completions must never move the visible bar backward. + const progress = (stage: TuiStartupStage) => { + setStartupStage((current) => + TUI_STARTUP_STAGES.indexOf(stage) > TUI_STARTUP_STAGES.indexOf(current) ? stage : current, + ) + } 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) - const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" - if (renderer.isDestroyed) return - + // Mount before terminal theme detection so startup never presents a blank alternate screen. await render(() => { return ( - { - if (renderer.isDestroyed) return - exit.reason = reason - destroyRenderer(renderer) - }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ) }, renderer) + + // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. + void renderer.getPalette({ size: 16 }).catch(() => undefined) + const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" + if (renderer.isDestroyed) return + batch(() => { + setStartupMode(mode) + progress("settings") + }) }) yield* Deferred.await(shutdown) return { epilogue: exit.epilogue, reason: exit.reason } @@ -416,7 +451,11 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi console.error("Failed to load TUI plugins", error) }) .finally(() => { - setReady(true) + batch(() => { + setReady(true) + // Plugin settlement is the point where the main interface becomes usable. + startup.progress?.("ready") + }) }) // Let selection copy/dismiss win ahead of normal bindings when explicit copy is required. @@ -1126,9 +1165,6 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi - - - ) } diff --git a/packages/tui/src/component/startup-loading.tsx b/packages/tui/src/component/startup-loading.tsx index 6665c0c2e8c4..c759f7ae194b 100644 --- a/packages/tui/src/component/startup-loading.tsx +++ b/packages/tui/src/component/startup-loading.tsx @@ -1,63 +1,69 @@ -import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js" -import { useTheme } from "../context/theme" -import { Spinner } from "./spinner" +import { createMemo, Show, type ParentProps } from "solid-js" +import { useTerminalDimensions } from "@opentui/solid" +import { DEFAULT_THEMES, resolveTheme } from "../theme" +import { TUI_STARTUP_STAGES, type TuiStartupStage } from "../context/runtime" -export function StartupLoading(props: { ready: () => boolean }) { - const theme = useTheme().theme - const [show, setShow] = createSignal(false) - const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins...")) - let wait: NodeJS.Timeout | undefined - let hold: NodeJS.Timeout | undefined - let stamp = 0 - - createEffect(() => { - if (props.ready()) { - if (wait) { - clearTimeout(wait) - wait = undefined - } - if (!show()) return - if (hold) return - - const left = 3000 - (Date.now() - stamp) - if (left <= 0) { - setShow(false) - return - } - - hold = setTimeout(() => { - hold = undefined - setShow(false) - }, left).unref() - return - } - - if (hold) { - clearTimeout(hold) - hold = undefined - } - if (show()) return - if (wait) return - - wait = setTimeout(() => { - wait = undefined - stamp = Date.now() - setShow(true) - }, 500).unref() - }) +const labels: Record = { + terminal: "Initializing terminal...", + settings: "Loading local settings...", + workspace: "Loading workspace and providers...", + theme: "Applying theme...", + plugins: "Loading plugins...", + ready: "Ready", +} - onCleanup(() => { - if (wait) clearTimeout(wait) - if (hold) clearTimeout(hold) - }) +export function StartupLoading( + props: ParentProps<{ + stage: () => TuiStartupStage + mode: () => "dark" | "light" | undefined + hidden: boolean + }>, +) { + const dimensions = useTerminalDimensions() + // This renders before KV and Theme providers exist, so use the built-in palette directly. + const theme = createMemo(() => resolveTheme(DEFAULT_THEMES.opencode!, props.mode() ?? "dark")) + const progress = createMemo(() => startupProgress(props.stage(), Math.max(4, Math.min(24, dimensions().width - 8)))) return ( - - - - {text()} + + {props.children} + + + + {progress().bar} + + {progress().completed}/{progress().total} {progress().label} + + - - + + ) } + +export function startupProgress(stage: TuiStartupStage, width: number) { + const completed = TUI_STARTUP_STAGES.indexOf(stage) + const total = TUI_STARTUP_STAGES.length - 1 + const filled = Math.round((completed / total) * width) + return { + bar: `[${"#".repeat(filled)}${"-".repeat(width - filled)}]`, + completed, + total, + label: labels[stage], + } +} diff --git a/packages/tui/src/context/kv.tsx b/packages/tui/src/context/kv.tsx index 7b90c95f593d..9c7108aaa76e 100644 --- a/packages/tui/src/context/kv.tsx +++ b/packages/tui/src/context/kv.tsx @@ -4,13 +4,14 @@ import { createSimpleContext } from "./helper" import { Flock } from "@opencode-ai/core/util/flock" import { Global } from "@opencode-ai/core/global" import { readJson, writeJsonAtomic } from "../util/persistence" -import { useTuiPaths } from "./runtime" +import { useTuiPaths, useTuiStartup } from "./runtime" import path from "path" export const { use: useKV, provider: KVProvider } = createSimpleContext({ name: "KV", init: () => { const paths = useTuiPaths() + const startup = useTuiStartup() void Global.Path.state const file = path.join(paths.state, "kv.json") const lock = `tui-kv:${file}` @@ -27,6 +28,8 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ console.error("Failed to read KV state", { error }) }) .finally(() => { + // KV gates every downstream provider, so workspace loading starts when this gate opens. + startup.progress?.("workspace") setReady(true) }) diff --git a/packages/tui/src/context/runtime.tsx b/packages/tui/src/context/runtime.tsx index 281049fe5786..907f462dff2b 100644 --- a/packages/tui/src/context/runtime.tsx +++ b/packages/tui/src/context/runtime.tsx @@ -13,9 +13,14 @@ export type TuiTerminalEnvironment = Readonly<{ displayServer?: "wayland" | "x11" }> +// Progress counts completed readiness gates, not elapsed startup time. +export const TUI_STARTUP_STAGES = ["terminal", "settings", "workspace", "theme", "plugins", "ready"] as const +export type TuiStartupStage = (typeof TUI_STARTUP_STAGES)[number] + export type TuiStartup = Readonly<{ initialRoute?: unknown skipInitialLoading: boolean + progress?(stage: TuiStartupStage): void }> const PathsContext = createContext() diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index d0511c5183e4..f1760ab2811d 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -509,6 +509,8 @@ export const { }) }) .then(() => { + // Only blocking workspace data affects readiness; secondary synchronization continues below. + startup.progress?.("theme") if (store.status !== "complete") setStore("status", "partial") // non-blocking void Promise.all([ diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 909dd69ac249..8acb454e903d 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -28,6 +28,7 @@ import { Global } from "@opencode-ai/core/global" import { Glob } from "@opencode-ai/core/util/glob" import { readFile } from "node:fs/promises" import path from "node:path" +import { useTuiStartup } from "./runtime" export type ThemeSource = Readonly<{ discover(): Promise> @@ -105,6 +106,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const renderer = useRenderer() const config = useTuiConfig() const kv = useKV() + const startup = useTuiStartup() const themes = props.source ?? themeSource const pick = (value: unknown) => { if (value === "dark" || value === "light") return value @@ -145,6 +147,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ onMount(() => { void Promise.allSettled([resolveSystemTheme(store.mode), syncCustomThemes()]).finally(() => { + // Opening the theme gate mounts App, where plugins are the final blocking phase. + startup.progress?.("plugins") setStore("ready", true) }) }) diff --git a/packages/tui/test/component/startup-loading.test.tsx b/packages/tui/test/component/startup-loading.test.tsx new file mode 100644 index 000000000000..049f6f77741e --- /dev/null +++ b/packages/tui/test/component/startup-loading.test.tsx @@ -0,0 +1,66 @@ +import { afterEach, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { StartupLoading, startupProgress } from "../../src/component/startup-loading" + +let setup: Awaited> | undefined + +afterEach(() => { + setup?.renderer.destroy() + setup = undefined +}) + +test("reports completed startup stages", () => { + expect(startupProgress("terminal", 10)).toEqual({ + bar: "[----------]", + completed: 0, + total: 5, + label: "Initializing terminal...", + }) + expect(startupProgress("workspace", 10)).toEqual({ + bar: "[####------]", + completed: 2, + total: 5, + label: "Loading workspace and providers...", + }) + expect(startupProgress("ready", 10)).toEqual({ + bar: "[##########]", + completed: 5, + total: 5, + label: "Ready", + }) +}) + +test("renders progress above startup content", async () => { + setup = await testRender( + () => ( + "workspace"} mode={() => "dark"} hidden={false}> + Application + + ), + { width: 40, height: 10 }, + ) + await setup.renderOnce() + await setup.renderOnce() + + const frame = setup.captureCharFrame() + expect(frame).toContain("[##########--------------]") + expect(frame).toContain("2/5 Loading workspace and providers...") + expect(frame).not.toContain("Application") +}) + +test("reveals startup content when ready", async () => { + setup = await testRender( + () => ( + "ready"} mode={() => "dark"} hidden={false}> + Application + + ), + { width: 40, height: 10 }, + ) + await setup.renderOnce() + await setup.renderOnce() + + const frame = setup.captureCharFrame() + expect(frame).toContain("Application") + expect(frame).not.toContain("5/5 Ready") +})