From 56a7c06a806ffaeac570f215ea74ca0dbfc43690 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 11 Jul 2026 15:42:31 -0400 Subject: [PATCH] fix(cli): hand update completion directly to the TUI (#36455) --- .../cli/src/services/update-preflight.tsx | 457 +++++++++++------- 1 file changed, 292 insertions(+), 165 deletions(-) diff --git a/packages/cli/src/services/update-preflight.tsx b/packages/cli/src/services/update-preflight.tsx index bb8f5a1a84..1be9d1638d 100644 --- a/packages/cli/src/services/update-preflight.tsx +++ b/packages/cli/src/services/update-preflight.tsx @@ -1,31 +1,34 @@ /** @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. +// 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 { createScrollbackWriter, render, useTerminalDimensions } from "@opentui/solid" +import { render, useTerminalDimensions } from "@opentui/solid" +import { InstallationVersion } from "@opencode-ai/core/installation/version" 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" +import { go } from "@opencode-ai/tui/logo" +import { + batch, + createEffect, + createMemo, + createSignal, + For, + Index, + on, + onCleanup, + onMount, + Show, + untrack, +} 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 +const stageFloor = 480 +const transitionDuration = 420 +const completionHold = 650 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 } @@ -34,7 +37,10 @@ export const make = (): Handle => { return { begin: (from) => { if (!process.stdout.isTTY || !process.stdin.isTTY) return false - session ??= open(from).catch(() => undefined) + session ??= open(from).catch(() => { + process.stderr.write("Restarting background server (version mismatch)...\n") + return undefined + }) return true }, finish: async () => { @@ -56,6 +62,10 @@ type Session = { async function open(from?: string): Promise { registerOpencodeSpinner() const [active, setActive] = createSignal(0) + const [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running") + const [failure, setFailure] = createSignal("") + const [animating, setAnimating] = createSignal(true) + let resolveOutcome: (() => void) | undefined const renderer = await createCliRenderer({ stdin: process.stdin, useMouse: false, @@ -70,96 +80,124 @@ async function open(from?: string): Promise { consoleMode: "disabled", clearOnShutdown: false, }) - const renderTask = render(() => , renderer) - void renderTask.catch(() => {}) - renderer.requestRender() + await render( + () => ( + resolveOutcome?.()} + /> + ), + renderer, + ).catch((error) => { + if (!renderer.isDestroyed) renderer.destroy() + throw error + }) let shownAt = performance.now() const advance = async (stage: number) => { const remaining = stageFloor - (performance.now() - shownAt) - if (remaining > 0) await sleep(remaining) + if (remaining > 0) await Bun.sleep(remaining) + if (outcome() !== "running") return 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. + // Service.start currently exposes only its start boundary, so this first + // transition is time-based. Finer lifecycle callbacks remain follow-up work. 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) + const transitionTo = async (next: "success" | "failure", hold: number) => { + const settled = Promise.withResolvers() + resolveOutcome = settled.resolve + setOutcome(next) + const completed = await Promise.race([ + settled.promise.then(() => true), + Bun.sleep(transitionDuration + 500).then(() => false), + ]) + resolveOutcome = undefined + 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 settled: Promise | undefined + const settle = (task: () => Promise) => (settled ??= task()) return { - finish: async () => { - await auto - await advance(2) - await sleep(stageFloor) - await close(() => ( - - - - OpenCode - - updated to - {InstallationVersion} - - )) - }, - fail: async (message) => { - await close(() => ( - - ! - {message} - - )) - }, + finish: () => + settle(async () => { + await auto + await advance(2) + await Bun.sleep(stageFloor) + await transitionTo("success", completionHold) + await close() + }), + fail: (message) => + settle(async () => { + setFailure(message) + await transitionTo("failure", 250) + await close() + }), } } -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), + muted: RGBA.fromHex("#808080"), success: RGBA.fromHex("#8bd5a5"), - text: RGBA.defaultForeground(), + text: RGBA.fromHex("#eeeeee"), } -// 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 monogram = go.right.slice(1) 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 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, + ) }) const railRamp = ramp(colors.accentDim, colors.accentBright) -const textRamp = ramp(textDim, textBright) +const monogramRamp = ramp(colors.muted, colors.accent) +const rampCache = new Map>() +const rampFor = (color: RGBA) => { + const cached = rampCache.get(color) + if (cached) return cached + const result = ramp(textDim, color) + rampCache.set(color, result) + return result +} const shade = (palette: ReadonlyArray, brightness: number) => palette[Math.round(Math.max(0, Math.min(1, brightness)) * rampSteps)] -function Monogram() { +type Cell = { readonly char: string; readonly color: RGBA; readonly bold?: boolean } +const styled = (text: string, color: RGBA, bold?: boolean): Cell[] => + Array.from(text).map((char) => ({ char, color, bold })) +const phrase = (...segments: ReadonlyArray): Cell[] => + segments.flatMap((segment, index) => [ + ...(index > 0 ? styled(" ", colors.muted) : []), + ...styled(segment[0], segment[1], segment[2]), + ]) + +function Monogram(props: { ink: () => RGBA }) { + const shadow = createMemo(() => { + const ink = props.ink() + return RGBA.fromValues(ink.r * 0.25, ink.g * 0.25, ink.b * 0.25) + }) return ( @@ -168,11 +206,11 @@ function Monogram() { {(char) => char === "_" ? ( - + {" "} ) : ( - + {char} ) @@ -185,45 +223,140 @@ function Monogram() { ) } -function UpdateFooter(props: { from?: string; active: () => number; renderer: CliRenderer }) { +type CellTransition = { from: Cell[]; to: Cell[]; done?: () => void } + +function createTransition(render: (transition: CellTransition, progress: number) => Cell[]) { + const [state, setState] = createSignal<{ from: Cell[]; to: Cell[]; done?: () => void } | undefined>() + const [progress, setProgress] = createSignal(0) + let elapsed = 0 + const cells = createMemo(() => { + const transition = state() + if (!transition) return undefined + return render(transition, progress()) + }) + return { + start(from: Cell[], to: Cell[], done?: () => void) { + elapsed = 0 + setProgress(0) + setState({ from, to, done }) + }, + tick(deltaTime: number) { + const transition = state() + if (!transition) return + elapsed = Math.min(transitionDuration, elapsed + deltaTime) + setProgress(elapsed / transitionDuration) + if (elapsed < transitionDuration) return + setState(undefined) + transition.done?.() + }, + cells, + progress, + } +} + +const createSweep = () => + createTransition((transition, progress) => { + const length = Math.max(transition.from.length, transition.to.length) + const front = smoothstep(progress) * (length + 2 * sweepBlend) - sweepBlend + return Array.from({ length }, (_, index) => { + const passed = Math.max(0, Math.min(1, (front - index) / sweepBlend)) + const brightness = smoothstep(Math.abs(passed * 2 - 1)) + const cell = (passed >= 0.5 ? transition.to[index] : transition.from[index]) ?? { + char: " ", + color: colors.text, + } + return { ...cell, color: shade(rampFor(cell.color), brightness) } + }) + }) + +const createFade = () => + createTransition((transition, progress) => { + const entering = progress >= 0.5 + const brightness = smoothstep(entering ? progress * 2 - 1 : 1 - progress * 2) + return (entering ? transition.to : transition.from).map((cell) => ({ + ...cell, + color: shade(rampFor(cell.color), brightness), + })) + }) + +const smoothstep = (value: number) => value * value * (3 - 2 * value) +const frameDone = Promise.resolve() + +function UpdateFooter(props: { + from?: string + active: () => number + outcome: () => "running" | "success" | "failure" + failure: () => string + animating: () => boolean + renderer: CliRenderer + onOutcomeSettled: () => void +}) { 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 + const headerFade = createFade() + const statusSweep = createSweep() + const runningHeader = () => + phrase( + ["OpenCode", colors.muted, true], + ["is updating", colors.muted], + ...(props.from + ? ([ + ["from", colors.muted], + [props.from, colors.accentDim], + ] as const) + : []), + ["to", colors.muted], + [InstallationVersion, colors.accent], + ) + const completedHeader = phrase( + ["OpenCode", colors.muted, true], + ["updated to", colors.muted], + [InstallationVersion, colors.accent], + ) + const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted]) + const outcomeStatus = () => + props.outcome() === "success" + ? [...styled("✓", colors.success), ...styled(" Ready", colors.text)] + : [...styled("!", colors.error), ...styled(" " + props.failure(), colors.text)] 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), - } - }) - }) + createEffect( + on(props.active, (index) => { + if (props.outcome() !== "running") return + const next = stages[index] + if (next === previousStage) return + statusSweep.start(styled(previousStage, colors.text), styled(next, colors.text)) + previousStage = next + }), + ) + createEffect( + on( + props.outcome, + (outcome) => { + if (outcome === "running") return + const visibleStatus = untrack(statusSweep.cells) ?? styled(previousStage, colors.text) + headerFade.start(runningHeader(), outcome === "success" ? completedHeader : pausedHeader) + statusSweep.start([...styled(" ", colors.text), ...visibleStatus], outcomeStatus(), props.onOutcomeSettled) + }, + { defer: true }, + ), + ) + const header = createMemo( + () => + headerFade.cells() ?? + (props.outcome() === "success" + ? completedHeader + : props.outcome() === "failure" + ? pausedHeader + : runningHeader()), + ) + const monogramInk = createMemo(() => + props.outcome() === "success" ? shade(monogramRamp, smoothstep(headerFade.progress())) : colors.muted, + ) const rail = createMemo(() => { - const width = Math.max(8, Math.min(30, term().width - 39)) + 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) @@ -239,70 +372,45 @@ function UpdateFooter(props: { from?: string; active: () => number; renderer: Cl 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 frame = (deltaTime: number) => { + if (!props.animating()) return frameDone 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 + const target = props.outcome() === "success" ? 1 : (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) - } + batch(() => { + setPosition(Math.max(0, Math.min(1, value))) + setPulse(phase) + }) + headerFade.tick(deltaTime) + statusSweep.tick(deltaTime) + return frameDone } 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} + {props.outcome() === "success" ? stages.length : props.active() + 1}/{stages.length} @@ -310,4 +418,23 @@ function UpdateFooter(props: { from?: string; active: () => number; renderer: Cl ) } +function CellLine(props: { cells: ReadonlyArray }) { + return ( + + + {(cell) => ( + + {cell().char} + + )} + + + ) +} + export * as UpdatePreflight from "./update-preflight"