From 1ccaca826e23240e3ec07368d423e38e279b0c32 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 11 Jul 2026 14:43:16 -0400 Subject: [PATCH] feat(cli): update preflight UI while replacing the background service (#36448) --- packages/cli/src/commands/handlers/default.ts | 15 +- .../cli/src/services/update-preflight.tsx | 313 ++++++++++++++++++ packages/client/src/effect/service.ts | 9 +- 3 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/services/update-preflight.tsx diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 5e5509ceb0..bb03b6b472 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -8,6 +8,7 @@ import { Runtime } from "../../framework/runtime" import { Effect, Option } from "effect" import { Server } from "../../services/server" import { Updater } from "../../services/updater" +import { UpdatePreflight } from "../../services/update-preflight" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { @@ -15,16 +16,24 @@ export default Runtime.handler(Commands, (input) => if (requestedDirectory !== undefined) process.chdir(requestedDirectory) const updater = yield* Updater.Service yield* updater.check().pipe(Effect.forkScoped) + const preflight = UpdatePreflight.make() const server = yield* Server.resolve({ server: Option.getOrUndefined(input.server), standalone: input.standalone, - onStart: (reason) => + onStart: (reason, existing) => { + if (reason === "version-mismatch" && preflight.begin(existing?.version)) return process.stderr.write( reason === "version-mismatch" ? "Restarting background server (version mismatch)...\n" : "Starting background server...\n", - ), - }) + ) + }, + }).pipe( + Effect.tapError(() => + Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")), + ), + ) + yield* Effect.promise(() => preflight.finish()) const config = TuiConfig.resolve({}, { terminalSuspend: false }) let disposeSlots: (() => void) | undefined const runFork = Effect.runForkWith(yield* Effect.context()) diff --git a/packages/cli/src/services/update-preflight.tsx b/packages/cli/src/services/update-preflight.tsx new file mode 100644 index 0000000000..bb8f5a1a84 --- /dev/null +++ b/packages/cli/src/services/update-preflight.tsx @@ -0,0 +1,313 @@ +/** @jsxImportSource @opentui/solid */ +// Update preflight: a split-footer status shown while a freshly launched CLI +// replaces a version-mismatched background service before the TUI attaches. +// The footer animates in the terminal's bottom rows, writes a one-line +// receipt into scrollback, and fully destroys its renderer before the TUI +// creates its own — the receipt survives above the TUI. +import { createCliRenderer, RGBA, TextAttributes, type CliRenderer } from "@opentui/core" +import { createScrollbackWriter, render, useTerminalDimensions } from "@opentui/solid" +import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner" +import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { createEffect, createMemo, createSignal, For, Index, onCleanup, onMount, Show, type JSX } from "solid-js" + +const stages = ["Keeping your session safe", "Starting the new background service", "Connecting to OpenCode"] as const + +// Real work is never delayed; each stage label lingers at least this long so +// its dissolve sweep and rail movement read as deliberate motion. +const stageFloor = 350 + +export type Handle = { + // Idempotent; returns false when no interactive terminal is available and + // the caller should fall back to plain stderr messaging. + readonly begin: (from?: string) => boolean + // Plays the final stage, writes the success receipt, and tears down the + // renderer. No-op when begin() never ran. + readonly finish: () => Promise + // Writes a failure receipt and tears down the renderer. No-op when begin() + // never ran. + readonly fail: (message: string) => Promise +} + +export const make = (): Handle => { + let session: Promise | undefined + return { + begin: (from) => { + if (!process.stdout.isTTY || !process.stdin.isTTY) return false + session ??= open(from).catch(() => undefined) + return true + }, + finish: async () => { + const active = await session + await active?.finish() + }, + fail: async (message) => { + const active = await session + await active?.fail(message) + }, + } +} + +type Session = { + readonly finish: () => Promise + readonly fail: (message: string) => Promise +} + +async function open(from?: string): Promise { + registerOpencodeSpinner() + const [active, setActive] = createSignal(0) + const renderer = await createCliRenderer({ + stdin: process.stdin, + useMouse: false, + autoFocus: false, + openConsoleOnError: false, + exitOnCtrlC: false, + exitSignals: [], + screenMode: "split-footer", + footerHeight: 4, + targetFps: 60, + externalOutputMode: "capture-stdout", + consoleMode: "disabled", + clearOnShutdown: false, + }) + const renderTask = render(() => , renderer) + void renderTask.catch(() => {}) + renderer.requestRender() + let shownAt = performance.now() + const advance = async (stage: number) => { + const remaining = stageFloor - (performance.now() - shownAt) + if (remaining > 0) await sleep(remaining) + setActive(stage) + shownAt = performance.now() + } + // The service replacement runs entirely inside Service.start without + // intermediate callbacks, so the first transition is time-based. Finer + // lifecycle hooks (draining, health polling) are a follow-up. + const auto = advance(1) + const close = async (content: () => JSX.Element) => { + await auto + renderer.writeToScrollback(createScrollbackWriter(content, { startOnNewLine: true, trailingNewline: true })) + renderer.requestRender() + await bounded(renderer.idle()) + renderer.externalOutputMode = "passthrough" + renderer.screenMode = "main-screen" + if (!renderer.isDestroyed) renderer.destroy() + await bounded(renderTask) + } + return { + finish: async () => { + await auto + await advance(2) + await sleep(stageFloor) + await close(() => ( + + + + OpenCode + + updated to + {InstallationVersion} + + )) + }, + fail: async (message) => { + await close(() => ( + + ! + {message} + + )) + }, + } +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const bounded = (task: Promise) => + Promise.race([task, sleep(1_000)]).catch(() => {}) + +const colors = { + accent: RGBA.fromHex("#a6b8ff"), + accentBright: RGBA.fromHex("#eef1ff"), + accentDim: RGBA.fromHex("#596998"), + error: RGBA.fromHex("#ff8192"), + muted: RGBA.fromIndex(8), + success: RGBA.fromHex("#8bd5a5"), + text: RGBA.defaultForeground(), +} + +// The "O" from the OpenCode logo: exactly footer height. "_" is the +// shadow-filled interior, matching the wordmark's rendering trick. +const monogram = ["█▀▀█", "█__█", "▀▀▀▀"] +const monogramInk = RGBA.fromHex("#808080") +const monogramShadow = RGBA.fromValues(monogramInk.r * 0.25, monogramInk.g * 0.25, monogramInk.b * 0.25) + +const sweepBlend = 8 +const textBright = RGBA.fromHex("#eeeeee") +const textDim = RGBA.fromHex("#4c4c4c") + +// Brightness ramps are precomputed so per-frame cell updates reuse stable +// RGBA instances instead of allocating one per cell per tick. +const rampSteps = 32 +const ramp = (from: RGBA, to: RGBA) => + Array.from({ length: rampSteps + 1 }, (_, step) => { + const t = step / rampSteps + return RGBA.fromValues(from.r + (to.r - from.r) * t, from.g + (to.g - from.g) * t, from.b + (to.b - from.b) * t) + }) +const railRamp = ramp(colors.accentDim, colors.accentBright) +const textRamp = ramp(textDim, textBright) +const shade = (palette: ReadonlyArray, brightness: number) => + palette[Math.round(Math.max(0, Math.min(1, brightness)) * rampSteps)] + +function Monogram() { + return ( + + + {(line) => ( + + + {(char) => + char === "_" ? ( + + {" "} + + ) : ( + + {char} + + ) + } + + + )} + + + ) +} + +function UpdateFooter(props: { from?: string; active: () => number; renderer: CliRenderer }) { + const term = useTerminalDimensions() + const [position, setPosition] = createSignal(0) + const [pulse, setPulse] = createSignal(0) + const [sweep, setSweep] = createSignal<{ from: string } | undefined>(undefined) + const [sweepProgress, setSweepProgress] = createSignal(0) + let sweepValue = 0 + let sweepVelocity = 0 + let previousStage: string = stages[0] + createEffect(() => { + const next = stages[props.active()] + if (next === previousStage) return + sweepValue = 0 + sweepVelocity = 0 + setSweepProgress(0) + setSweep({ from: previousStage }) + previousStage = next + }) + // Stationary dissolve: a front sweeps left to right; ahead of it the old + // phrase stays bright, behind it the new phrase is bright, and characters + // dip to dim as the front passes over them. + const stageCells = createMemo(() => { + const state = sweep() + if (!state) return undefined + const target = stages[props.active()] + const progress = sweepProgress() + const length = Math.max(target.length, state.from.length) + const front = progress * (length + 2 * sweepBlend) - sweepBlend + return Array.from({ length }, (_, index) => { + const passed = Math.max(0, Math.min(1, (front - index) / sweepBlend)) + const brightness = Math.abs(passed * 2 - 1) + return { + char: (passed >= 0.5 ? target[index] : state.from[index]) ?? " ", + color: shade(textRamp, brightness), + } + }) + }) + const rail = createMemo(() => { + const width = Math.max(8, Math.min(30, term().width - 39)) + const filled = Math.round(position() * width) + const glowRadius = 6 + const span = Math.max(1, filled + glowRadius * 2) + const center = pulse() * span - glowRadius + 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) } + }) + }) + + onMount(() => { + let value = 0 + let velocity = 0 + let phase = 0 + // Springs integrate inside the renderer's own frame loop, so simulation + // steps and painted frames share one clock and one delta. + const frame = async (deltaTime: number) => { + const elapsed = Math.min(0.032, deltaTime / 1_000) + const stiffness = 110 + const damping = 2 * Math.sqrt(stiffness) + const target = (props.active() + 1) / stages.length + velocity += (stiffness * (target - value) - damping * velocity) * elapsed + value += velocity * elapsed + setPosition(Math.max(0, Math.min(1, value))) + phase = (phase + deltaTime / 900) % 1 + setPulse(phase) + if (sweep()) { + const sweepStiffness = 200 + const sweepDamping = 2 * Math.sqrt(sweepStiffness) + sweepVelocity += (sweepStiffness * (1 - sweepValue) - sweepDamping * sweepVelocity) * elapsed + sweepValue += sweepVelocity * elapsed + if (sweepValue >= 0.995) setSweep(undefined) + else setSweepProgress(sweepValue) + } + } + props.renderer.setFrameCallback(frame) + onCleanup(() => props.renderer.removeFrameCallback(frame)) + }) + + return ( + + + + + + OpenCode + + is updating + + from + {props.from} + + to + {InstallationVersion} + + + + + {stages[props.active()]} + + } + > + {(cells) => ( + + {(cell) => {cell().char}} + + )} + + + + + {(segment) => {segment().char}} + + + {props.active() + 1}/{stages.length} + + + + + ) +} + +export * as UpdatePreflight from "./update-preflight" diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index de98066a09..f047d462bf 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -35,7 +35,10 @@ export type Options = { export type StartReason = "missing" | "version-mismatch" export type StartOptions = Options & { - readonly onStart?: (reason: StartReason) => void + // Called once when start() decides it must spawn: either no service was + // found, or a healthy service with a different version is being replaced. + // `existing` carries the registration of the service being replaced. + readonly onStart?: (reason: StartReason, existing?: Info) => void } // Read-only lookup: registration file plus health check and version gate. @@ -57,7 +60,9 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions const compatible = yield* discover(options) if (compatible !== undefined) return compatible const mismatched = yield* find(options) - yield* Effect.sync(() => options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch")) + yield* Effect.sync(() => + options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch", mismatched?.info), + ) if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore) const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]