fix(cli): keep update preflight through TUI loading (#36475)

This commit is contained in:
Kit Langton 2026-07-11 22:19:13 -04:00 committed by GitHub
commit e22e3b8f2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 127 additions and 46 deletions

View file

@ -17,6 +17,7 @@ export default Runtime.handler(Commands, (input) =>
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
const preflight = UpdatePreflight.make()
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
const server = yield* Server.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
@ -33,7 +34,7 @@ export default Runtime.handler(Commands, (input) =>
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
),
)
yield* Effect.promise(() => preflight.finish())
preflight.loading()
const config = yield* TuiConfig.load()
let disposeSlots: (() => void) | undefined
const runFork = Effect.runForkWith(yield* Effect.context())
@ -41,6 +42,7 @@ export default Runtime.handler(Commands, (input) =>
server,
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
config,
terminalHandoff: () => preflight.finish(),
log: (level, message, tags) => {
const effect =
level === "debug"

View file

@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
// Split-footer status shown while a freshly launched CLI replaces a
// version-mismatched background service before the TUI attaches.
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer } from "@opentui/core"
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
import { render, useTerminalDimensions } from "@opentui/solid"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
@ -21,15 +21,23 @@ import {
untrack,
} from "solid-js"
const stages = ["Keeping your session safe", "Starting the new background service", "Connecting to OpenCode"] as const
const stages = ["Keeping your session safe", "Starting the new background service", "Loading OpenCode"] as const
const stageFloor = 480
const transitionDuration = 420
const completionHold = 650
export type Handle = {
readonly begin: (from?: string) => boolean
readonly finish: () => Promise<void>
readonly loading: () => void
readonly finish: () => Promise<Handoff | undefined>
readonly fail: (message: string) => Promise<void>
readonly close: () => Promise<void>
}
export type Handoff = {
readonly renderer: CliRenderer
readonly mode: ThemeMode | null
readonly complete: () => void
}
export const make = (): Handle => {
@ -43,20 +51,29 @@ export const make = (): Handle => {
})
return true
},
loading: () => {
void session?.then((active) => active?.loading())
},
finish: async () => {
const active = await session
await active?.finish()
return active?.finish()
},
fail: async (message) => {
const active = await session
await active?.fail(message)
},
close: async () => {
const active = await session
await active?.close()
},
}
}
type Session = {
readonly finish: () => Promise<void>
readonly loading: () => Promise<void>
readonly finish: () => Promise<Handoff>
readonly fail: (message: string) => Promise<void>
readonly close: () => Promise<void>
}
async function open(from?: string): Promise<Session> {
@ -65,6 +82,7 @@ async function open(from?: string): Promise<Session> {
const [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running")
const [failure, setFailure] = createSignal("")
const [animating, setAnimating] = createSignal(true)
const [visible, setVisible] = createSignal(true)
let resolveOutcome: (() => void) | undefined
const renderer = await createCliRenderer({
stdin: process.stdin,
@ -72,25 +90,30 @@ async function open(from?: string): Promise<Session> {
autoFocus: false,
openConsoleOnError: false,
exitOnCtrlC: false,
exitSignals: [],
screenMode: "split-footer",
footerHeight: 4,
targetFps: 60,
useKittyKeyboard: {},
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
clearOnShutdown: false,
})
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
await render(
() => (
<UpdateFooter
from={from}
active={active}
outcome={outcome}
failure={failure}
animating={animating}
renderer={renderer}
onOutcomeSettled={() => resolveOutcome?.()}
/>
<Show when={visible()}>
<UpdateFooter
from={from}
active={active}
outcome={outcome}
failure={failure}
animating={animating}
renderer={renderer}
onOutcomeSettled={() => resolveOutcome?.()}
/>
</Show>
),
renderer,
).catch((error) => {
@ -98,9 +121,12 @@ async function open(from?: string): Promise<Session> {
throw error
})
let shownAt = performance.now()
const advance = async (stage: number) => {
const waitForStage = async () => {
const remaining = stageFloor - (performance.now() - shownAt)
if (remaining > 0) await Bun.sleep(remaining)
}
const advance = async (stage: number) => {
await waitForStage()
if (outcome() !== "running") return
setActive(stage)
shownAt = performance.now()
@ -120,30 +146,53 @@ async function open(from?: string): Promise<Session> {
setAnimating(false)
if (completed) await Bun.sleep(hold)
}
const close = async () => {
setAnimating(false)
if (renderer.isDestroyed) return
renderer.pause()
await Promise.race([renderer.idle(), Bun.sleep(500)])
renderer.destroy()
}
let closing: Promise<void> | undefined
let transferred = false
const close = () =>
(closing ??= (async () => {
if (transferred) return
setAnimating(false)
if (renderer.isDestroyed) return
renderer.pause()
await Promise.race([renderer.idle(), Bun.sleep(500)])
renderer.destroy()
})())
let loading: Promise<void> | undefined
const load = () =>
(loading ??= (async () => {
await auto
await advance(2)
})())
let settled: Promise<void> | undefined
const settle = (task: () => Promise<void>) => (settled ??= task())
return {
finish: () =>
settle(async () => {
await auto
await advance(2)
await Bun.sleep(stageFloor)
loading: load,
finish: async () => {
await settle(async () => {
await load()
await waitForStage()
await transitionTo("success", completionHold)
await close()
}),
})
const mode = await terminalMode
renderer.externalOutputMode = "passthrough"
renderer.screenMode = "alternate-screen"
renderer.consoleMode = "console-overlay"
renderer.requestRender()
await Promise.race([renderer.idle(), Bun.sleep(500)])
transferred = true
return {
renderer,
mode,
complete: () => setVisible(false),
}
},
fail: (message) =>
settle(async () => {
setFailure(message)
await transitionTo("failure", 250)
await close()
}),
close,
}
}
@ -162,15 +211,14 @@ const sweepBlend = 8
const textDim = RGBA.fromHex("#4c4c4c")
const rampSteps = 32
const blend = (from: RGBA, to: RGBA, amount: number) =>
RGBA.fromValues(
from.r + (to.r - from.r) * amount,
from.g + (to.g - from.g) * amount,
from.b + (to.b - from.b) * amount,
)
const ramp = (from: RGBA, to: RGBA) =>
Array.from({ length: rampSteps + 1 }, (_, step) => {
const amount = step / rampSteps
return RGBA.fromValues(
from.r + (to.r - from.r) * amount,
from.g + (to.g - from.g) * amount,
from.b + (to.b - from.b) * amount,
)
})
Array.from({ length: rampSteps + 1 }, (_, step) => blend(from, to, step / rampSteps))
const railRamp = ramp(colors.accentDim, colors.accentBright)
const monogramRamp = ramp(colors.muted, colors.accent)
const rampCache = new Map<RGBA, ReadonlyArray<RGBA>>()
@ -356,15 +404,21 @@ function UpdateFooter(props: {
const rail = createMemo(() => {
const width = Math.max(0, Math.min(30, term().width - 39))
if (width === 0) return []
if (props.outcome() === "success") return Array.from({ length: width }, () => ({ char: "━", color: colors.accent }))
const filled = Math.round(position() * width)
const glowRadius = 6
const span = Math.max(1, filled + glowRadius * 2)
const center = pulse() * span - glowRadius
const success = props.outcome() === "success"
const completion = smoothstep(headerFade.progress())
return Array.from({ length: width }, (_, index) => {
if (index >= filled) return { char: "·", color: colors.muted }
const glow = Math.max(0, 1 - Math.abs(index - center) / glowRadius) ** 2
return { char: "━", color: shade(railRamp, glow) }
const color =
index >= filled
? colors.muted
: shade(railRamp, Math.max(0, 1 - Math.abs(index - center) / glowRadius) ** 2)
return {
char: success || index < filled ? "━" : "·",
color: success ? blend(color, colors.accent, completion) : color,
}
})
})

View file

@ -12,7 +12,14 @@ import { LogProvider, useLog, type LogSink } from "./context/log"
import { ExitProvider, useExit } from "./context/exit"
import { EpilogueProvider } from "./context/epilogue"
import * as Selection from "./util/selection"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import {
CliRenderEvents,
createCliRenderer,
MouseButton,
type CliRenderer,
type CliRendererConfig,
type ThemeMode,
} from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
import {
Switch,
@ -150,6 +157,14 @@ export type TuiInput = {
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
pluginHost: TuiPluginHost
terminalHandoff?: () => Promise<
| {
readonly renderer: CliRenderer
readonly mode: ThemeMode | null
readonly complete: () => void
}
| undefined
>
log?: LogSink
}
@ -194,6 +209,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
Effect.map((response) => response.location.directory),
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
)
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
const reconnectEndpoint = input.server.reconnect
const reconnect = reconnectEndpoint
? async (attempt: number) => {
@ -225,6 +241,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
},
} satisfies CliRendererConfig
if (handoff) {
handoff.renderer.useMouse = options.useMouse
return handoff.renderer
}
if (process.env.OPENCODE_DRIVE) {
const { Drive } = await import("@opencode-ai/simulation/frontend")
return Drive.create(options)
@ -267,7 +288,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
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"
const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
if (renderer.isDestroyed) return
await render(() => {
@ -392,6 +413,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
</LogProvider>
)
}, renderer)
if (handoff) {
renderer.once(CliRenderEvents.FRAME, handoff.complete)
renderer.requestRender()
}
})
yield* Deferred.await(shutdown)
return { epilogue: exit.epilogue, reason: exit.reason }