fix(cli): hand update completion directly to the TUI (#36455)

This commit is contained in:
Kit Langton 2026-07-11 15:42:31 -04:00 committed by GitHub
commit 56a7c06a80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,31 +1,34 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
// Update preflight: a split-footer status shown while a freshly launched CLI // Split-footer status shown while a freshly launched CLI replaces a
// replaces a version-mismatched background service before the TUI attaches. // 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 { 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 { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner" import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { go } from "@opencode-ai/tui/logo"
import { createEffect, createMemo, createSignal, For, Index, onCleanup, onMount, Show, type JSX } from "solid-js" 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 const stages = ["Keeping your session safe", "Starting the new background service", "Connecting to OpenCode"] as const
const stageFloor = 480
// Real work is never delayed; each stage label lingers at least this long so const transitionDuration = 420
// its dissolve sweep and rail movement read as deliberate motion. const completionHold = 650
const stageFloor = 350
export type Handle = { 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 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<void> readonly finish: () => Promise<void>
// Writes a failure receipt and tears down the renderer. No-op when begin()
// never ran.
readonly fail: (message: string) => Promise<void> readonly fail: (message: string) => Promise<void>
} }
@ -34,7 +37,10 @@ export const make = (): Handle => {
return { return {
begin: (from) => { begin: (from) => {
if (!process.stdout.isTTY || !process.stdin.isTTY) return false 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 return true
}, },
finish: async () => { finish: async () => {
@ -56,6 +62,10 @@ type Session = {
async function open(from?: string): Promise<Session> { async function open(from?: string): Promise<Session> {
registerOpencodeSpinner() registerOpencodeSpinner()
const [active, setActive] = createSignal(0) 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({ const renderer = await createCliRenderer({
stdin: process.stdin, stdin: process.stdin,
useMouse: false, useMouse: false,
@ -70,96 +80,124 @@ async function open(from?: string): Promise<Session> {
consoleMode: "disabled", consoleMode: "disabled",
clearOnShutdown: false, clearOnShutdown: false,
}) })
const renderTask = render(() => <UpdateFooter from={from} active={active} renderer={renderer} />, renderer) await render(
void renderTask.catch(() => {}) () => (
renderer.requestRender() <UpdateFooter
from={from}
active={active}
outcome={outcome}
failure={failure}
animating={animating}
renderer={renderer}
onOutcomeSettled={() => resolveOutcome?.()}
/>
),
renderer,
).catch((error) => {
if (!renderer.isDestroyed) renderer.destroy()
throw error
})
let shownAt = performance.now() let shownAt = performance.now()
const advance = async (stage: number) => { const advance = async (stage: number) => {
const remaining = stageFloor - (performance.now() - shownAt) 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) setActive(stage)
shownAt = performance.now() shownAt = performance.now()
} }
// The service replacement runs entirely inside Service.start without // Service.start currently exposes only its start boundary, so this first
// intermediate callbacks, so the first transition is time-based. Finer // transition is time-based. Finer lifecycle callbacks remain follow-up work.
// lifecycle hooks (draining, health polling) are a follow-up.
const auto = advance(1) const auto = advance(1)
const close = async (content: () => JSX.Element) => { const transitionTo = async (next: "success" | "failure", hold: number) => {
await auto const settled = Promise.withResolvers<void>()
renderer.writeToScrollback(createScrollbackWriter(content, { startOnNewLine: true, trailingNewline: true })) resolveOutcome = settled.resolve
renderer.requestRender() setOutcome(next)
await bounded(renderer.idle()) const completed = await Promise.race([
renderer.externalOutputMode = "passthrough" settled.promise.then(() => true),
renderer.screenMode = "main-screen" Bun.sleep(transitionDuration + 500).then(() => false),
if (!renderer.isDestroyed) renderer.destroy() ])
await bounded(renderTask) 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<void> | undefined
const settle = (task: () => Promise<void>) => (settled ??= task())
return { return {
finish: async () => { finish: () =>
await auto settle(async () => {
await advance(2) await auto
await sleep(stageFloor) await advance(2)
await close(() => ( await Bun.sleep(stageFloor)
<box width="100%" flexDirection="row" gap={1}> await transitionTo("success", completionHold)
<text fg={colors.success}></text> await close()
<text fg={colors.muted} attributes={TextAttributes.BOLD}> }),
OpenCode fail: (message) =>
</text> settle(async () => {
<text fg={colors.muted}>updated to</text> setFailure(message)
<text fg={colors.accent}>{InstallationVersion}</text> await transitionTo("failure", 250)
</box> await close()
)) }),
},
fail: async (message) => {
await close(() => (
<box width="100%" flexDirection="row" gap={1}>
<text fg={colors.error}>!</text>
<text fg={colors.text}>{message}</text>
</box>
))
},
} }
} }
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const bounded = (task: Promise<unknown>) =>
Promise.race([task, sleep(1_000)]).catch(() => {})
const colors = { const colors = {
accent: RGBA.fromHex("#a6b8ff"), accent: RGBA.fromHex("#a6b8ff"),
accentBright: RGBA.fromHex("#eef1ff"), accentBright: RGBA.fromHex("#eef1ff"),
accentDim: RGBA.fromHex("#596998"), accentDim: RGBA.fromHex("#596998"),
error: RGBA.fromHex("#ff8192"), error: RGBA.fromHex("#ff8192"),
muted: RGBA.fromIndex(8), muted: RGBA.fromHex("#808080"),
success: RGBA.fromHex("#8bd5a5"), success: RGBA.fromHex("#8bd5a5"),
text: RGBA.defaultForeground(), text: RGBA.fromHex("#eeeeee"),
} }
// The "O" from the OpenCode logo: exactly footer height. "_" is the const monogram = go.right.slice(1)
// 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 sweepBlend = 8
const textBright = RGBA.fromHex("#eeeeee")
const textDim = RGBA.fromHex("#4c4c4c") 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 rampSteps = 32
const ramp = (from: RGBA, to: RGBA) => const ramp = (from: RGBA, to: RGBA) =>
Array.from({ length: rampSteps + 1 }, (_, step) => { Array.from({ length: rampSteps + 1 }, (_, step) => {
const t = step / rampSteps const amount = 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) 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 railRamp = ramp(colors.accentDim, colors.accentBright)
const textRamp = ramp(textDim, textBright) const monogramRamp = ramp(colors.muted, colors.accent)
const rampCache = new Map<RGBA, ReadonlyArray<RGBA>>()
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<RGBA>, brightness: number) => const shade = (palette: ReadonlyArray<RGBA>, brightness: number) =>
palette[Math.round(Math.max(0, Math.min(1, brightness)) * rampSteps)] 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<readonly [string, RGBA, boolean?]>): 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 ( return (
<box flexDirection="column"> <box flexDirection="column">
<For each={monogram}> <For each={monogram}>
@ -168,11 +206,11 @@ function Monogram() {
<For each={Array.from(line)}> <For each={Array.from(line)}>
{(char) => {(char) =>
char === "_" ? ( char === "_" ? (
<text bg={monogramShadow} selectable={false}> <text bg={shadow()} selectable={false}>
{" "} {" "}
</text> </text>
) : ( ) : (
<text fg={monogramInk} selectable={false}> <text fg={props.ink()} selectable={false}>
{char} {char}
</text> </text>
) )
@ -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 term = useTerminalDimensions()
const [position, setPosition] = createSignal(0) const [position, setPosition] = createSignal(0)
const [pulse, setPulse] = createSignal(0) const [pulse, setPulse] = createSignal(0)
const [sweep, setSweep] = createSignal<{ from: string } | undefined>(undefined) const headerFade = createFade()
const [sweepProgress, setSweepProgress] = createSignal(0) const statusSweep = createSweep()
let sweepValue = 0 const runningHeader = () =>
let sweepVelocity = 0 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] let previousStage: string = stages[0]
createEffect(() => { createEffect(
const next = stages[props.active()] on(props.active, (index) => {
if (next === previousStage) return if (props.outcome() !== "running") return
sweepValue = 0 const next = stages[index]
sweepVelocity = 0 if (next === previousStage) return
setSweepProgress(0) statusSweep.start(styled(previousStage, colors.text), styled(next, colors.text))
setSweep({ from: previousStage }) previousStage = next
previousStage = next }),
}) )
// Stationary dissolve: a front sweeps left to right; ahead of it the old createEffect(
// phrase stays bright, behind it the new phrase is bright, and characters on(
// dip to dim as the front passes over them. props.outcome,
const stageCells = createMemo(() => { (outcome) => {
const state = sweep() if (outcome === "running") return
if (!state) return undefined const visibleStatus = untrack(statusSweep.cells) ?? styled(previousStage, colors.text)
const target = stages[props.active()] headerFade.start(runningHeader(), outcome === "success" ? completedHeader : pausedHeader)
const progress = sweepProgress() statusSweep.start([...styled(" ", colors.text), ...visibleStatus], outcomeStatus(), props.onOutcomeSettled)
const length = Math.max(target.length, state.from.length) },
const front = progress * (length + 2 * sweepBlend) - sweepBlend { defer: true },
return Array.from({ length }, (_, index) => { ),
const passed = Math.max(0, Math.min(1, (front - index) / sweepBlend)) )
const brightness = Math.abs(passed * 2 - 1) const header = createMemo(
return { () =>
char: (passed >= 0.5 ? target[index] : state.from[index]) ?? " ", headerFade.cells() ??
color: shade(textRamp, brightness), (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 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 filled = Math.round(position() * width)
const glowRadius = 6 const glowRadius = 6
const span = Math.max(1, filled + glowRadius * 2) 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 value = 0
let velocity = 0 let velocity = 0
let phase = 0 let phase = 0
// Springs integrate inside the renderer's own frame loop, so simulation const frame = (deltaTime: number) => {
// steps and painted frames share one clock and one delta. if (!props.animating()) return frameDone
const frame = async (deltaTime: number) => {
const elapsed = Math.min(0.032, deltaTime / 1_000) const elapsed = Math.min(0.032, deltaTime / 1_000)
const stiffness = 110 const stiffness = 110
const damping = 2 * Math.sqrt(stiffness) 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 velocity += (stiffness * (target - value) - damping * velocity) * elapsed
value += velocity * elapsed value += velocity * elapsed
setPosition(Math.max(0, Math.min(1, value)))
phase = (phase + deltaTime / 900) % 1 phase = (phase + deltaTime / 900) % 1
setPulse(phase) batch(() => {
if (sweep()) { setPosition(Math.max(0, Math.min(1, value)))
const sweepStiffness = 200 setPulse(phase)
const sweepDamping = 2 * Math.sqrt(sweepStiffness) })
sweepVelocity += (sweepStiffness * (1 - sweepValue) - sweepDamping * sweepVelocity) * elapsed headerFade.tick(deltaTime)
sweepValue += sweepVelocity * elapsed statusSweep.tick(deltaTime)
if (sweepValue >= 0.995) setSweep(undefined) return frameDone
else setSweepProgress(sweepValue)
}
} }
props.renderer.setFrameCallback(frame) props.renderer.setFrameCallback(frame)
onCleanup(() => props.renderer.removeFrameCallback(frame)) onCleanup(() => props.renderer.removeFrameCallback(frame))
}) })
return ( return (
<box width="100%" height={4} flexDirection="row" gap={1}> <box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
<Monogram /> <Monogram ink={monogramInk} />
<box flexDirection="column" flexGrow={1}> <box flexDirection="column" flexGrow={1} overflow="hidden">
<box flexDirection="row" gap={1}> <CellLine cells={header()} />
<text fg={colors.muted} attributes={TextAttributes.BOLD}> <Show
OpenCode when={props.outcome() === "running"}
</text> fallback={<CellLine cells={statusSweep.cells() ?? outcomeStatus()} />}
<text fg={colors.muted}>is updating</text> >
<Show when={props.from}> <box flexDirection="row" gap={1}>
<text fg={colors.muted}>from</text> <spinner frames={SPINNER_FRAMES} interval={80} color={colors.accent} />
<text fg={colors.accentDim}>{props.from}</text> <CellLine cells={statusSweep.cells() ?? styled(stages[props.active()], colors.text)} />
</Show>
<text fg={colors.muted}>to</text>
<text fg={colors.accent}>{InstallationVersion}</text>
</box>
<box flexDirection="row" gap={1}>
<spinner frames={SPINNER_FRAMES} interval={80} color={colors.accent} />
<Show
when={stageCells()}
fallback={
<text fg={colors.text} truncate>
{stages[props.active()]}
</text>
}
>
{(cells) => (
<box flexDirection="row">
<Index each={cells()}>{(cell) => <text fg={cell().color}>{cell().char}</text>}</Index>
</box>
)}
</Show>
</box>
<box flexDirection="row" gap={1}>
<box flexDirection="row">
<Index each={rail()}>{(segment) => <text fg={segment().color}>{segment().char}</text>}</Index>
</box> </box>
</Show>
<box flexDirection="row" gap={1}>
<CellLine cells={rail()} />
<text fg={colors.muted}> <text fg={colors.muted}>
{props.active() + 1}/{stages.length} {props.outcome() === "success" ? stages.length : props.active() + 1}/{stages.length}
</text> </text>
</box> </box>
</box> </box>
@ -310,4 +418,23 @@ function UpdateFooter(props: { from?: string; active: () => number; renderer: Cl
) )
} }
function CellLine(props: { cells: ReadonlyArray<Cell> }) {
return (
<text truncate>
<Index each={props.cells}>
{(cell) => (
<span
style={{
fg: cell().color,
attributes: cell().bold ? TextAttributes.BOLD : TextAttributes.NONE,
}}
>
{cell().char}
</span>
)}
</Index>
</text>
)
}
export * as UpdatePreflight from "./update-preflight" export * as UpdatePreflight from "./update-preflight"