| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495 |
- /** @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, type ThemeMode } from "@opentui/core"
- import { render, useTerminalDimensions } from "@opentui/solid"
- import { OPENCODE_VERSION } from "../version"
- import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
- import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
- import { go } from "@opencode-ai/tui/logo"
- import { setTimeout } from "node:timers/promises"
- 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", "Loading OpenCode"] as const
- const stageFloor = 480
- const transitionDuration = 420
- const completionHold = 650
- export type Handle = {
- readonly begin: (from?: string) => boolean
- 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 => {
- let session: Promise<Session | undefined> | undefined
- return {
- begin: (from) => {
- if (!process.stdout.isTTY || !process.stdin.isTTY) return false
- session ??= open(from).catch(() => {
- process.stderr.write("Restarting background server (version mismatch)...\n")
- return undefined
- })
- return true
- },
- loading: () => {
- void session?.then((active) => active?.loading())
- },
- finish: async () => {
- const active = await session
- 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 loading: () => Promise<void>
- readonly finish: () => Promise<Handoff>
- readonly fail: (message: string) => Promise<void>
- readonly close: () => Promise<void>
- }
- async function open(from?: string): Promise<Session> {
- registerOpencodeSpinner()
- const [active, setActive] = createSignal(0)
- 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,
- useMouse: false,
- autoFocus: false,
- openConsoleOnError: false,
- exitOnCtrlC: false,
- screenMode: "split-footer",
- footerHeight: 4,
- targetFps: 60,
- useKittyKeyboard: {},
- consoleOptions: {
- keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
- },
- externalOutputMode: "capture-stdout",
- consoleMode: "disabled",
- })
- const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
- await render(
- () => (
- <Show when={visible()}>
- <UpdateFooter
- from={from}
- active={active}
- outcome={outcome}
- failure={failure}
- animating={animating}
- renderer={renderer}
- onOutcomeSettled={() => resolveOutcome?.()}
- />
- </Show>
- ),
- renderer,
- ).catch((error) => {
- if (!renderer.isDestroyed) renderer.destroy()
- throw error
- })
- let shownAt = performance.now()
- const waitForStage = async () => {
- const remaining = stageFloor - (performance.now() - shownAt)
- if (remaining > 0) await setTimeout(remaining)
- }
- const advance = async (stage: number) => {
- await waitForStage()
- if (outcome() !== "running") return
- setActive(stage)
- shownAt = performance.now()
- }
- // 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 transitionTo = async (next: "success" | "failure", hold: number) => {
- const settled = Promise.withResolvers<void>()
- resolveOutcome = settled.resolve
- setOutcome(next)
- const completed = await Promise.race([
- settled.promise.then(() => true),
- setTimeout(transitionDuration + 500).then(() => false),
- ])
- resolveOutcome = undefined
- setAnimating(false)
- if (completed) await setTimeout(hold)
- }
- 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(), setTimeout(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 {
- loading: load,
- finish: async () => {
- await settle(async () => {
- await load()
- await waitForStage()
- await transitionTo("success", completionHold)
- })
- const mode = await terminalMode
- renderer.externalOutputMode = "passthrough"
- renderer.screenMode = "alternate-screen"
- renderer.consoleMode = "console-overlay"
- renderer.requestRender()
- await Promise.race([renderer.idle(), setTimeout(500)])
- transferred = true
- return {
- renderer,
- mode,
- complete: () => setVisible(false),
- }
- },
- fail: (message) =>
- settle(async () => {
- setFailure(message)
- await transitionTo("failure", 250)
- await close()
- }),
- close,
- }
- }
- const colors = {
- accent: RGBA.fromHex("#a6b8ff"),
- accentBright: RGBA.fromHex("#eef1ff"),
- accentDim: RGBA.fromHex("#596998"),
- error: RGBA.fromHex("#ff8192"),
- muted: RGBA.fromHex("#808080"),
- success: RGBA.fromHex("#8bd5a5"),
- text: RGBA.fromHex("#eeeeee"),
- }
- const monogram = go.right.slice(1)
- 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) => 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>>()
- 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) =>
- palette[Math.round(Math.max(0, Math.min(1, brightness)) * rampSteps)]
- 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 (
- <box flexDirection="column">
- <For each={monogram}>
- {(line) => (
- <box flexDirection="row">
- <For each={Array.from(line)}>
- {(char) =>
- char === "_" ? (
- <text bg={shadow()} selectable={false}>
- {" "}
- </text>
- ) : (
- <text fg={props.ink()} selectable={false}>
- {char}
- </text>
- )
- }
- </For>
- </box>
- )}
- </For>
- </box>
- )
- }
- 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 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],
- [OPENCODE_VERSION, colors.accent],
- )
- const completedHeader = phrase(
- ["OpenCode", colors.muted, true],
- ["updated to", colors.muted],
- [OPENCODE_VERSION, 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(
- 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(0, Math.min(30, term().width - 39))
- if (width === 0) return []
- 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) => {
- 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,
- }
- })
- })
- onMount(() => {
- let value = 0
- let velocity = 0
- let phase = 0
- 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.outcome() === "success" ? 1 : (props.active() + 1) / stages.length
- velocity += (stiffness * (target - value) - damping * velocity) * elapsed
- value += velocity * elapsed
- phase = (phase + deltaTime / 900) % 1
- 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 (
- <box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
- <Monogram ink={monogramInk} />
- <box flexDirection="column" flexGrow={1} overflow="hidden">
- <CellLine cells={header()} />
- <Show
- when={props.outcome() === "running"}
- fallback={<CellLine cells={statusSweep.cells() ?? outcomeStatus()} />}
- >
- <box flexDirection="row" gap={1}>
- <spinner frames={SPINNER_FRAMES} interval={80} color={colors.accent} />
- <CellLine cells={statusSweep.cells() ?? styled(stages[props.active()], colors.text)} />
- </box>
- </Show>
- <box flexDirection="row" gap={1}>
- <CellLine cells={rail()} />
- <text fg={colors.muted}>
- {props.outcome() === "success" ? stages.length : props.active() + 1}/{stages.length}
- </text>
- </box>
- </box>
- </box>
- )
- }
- 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"
|