فهرست منبع

feat(cli): update preflight UI while replacing the background service (#36448)

Kit Langton 1 ماه پیش
والد
کامیت
1ccaca826e

+ 12 - 3
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())

+ 313 - 0
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<void>
+  // Writes a failure receipt and tears down the renderer. No-op when begin()
+  // never ran.
+  readonly fail: (message: string) => Promise<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(() => 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<void>
+  readonly fail: (message: string) => Promise<void>
+}
+
+async function open(from?: string): Promise<Session> {
+  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(() => <UpdateFooter from={from} active={active} renderer={renderer} />, 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(() => (
+        <box width="100%" flexDirection="row" gap={1}>
+          <text fg={colors.success}>✓</text>
+          <text fg={colors.muted} attributes={TextAttributes.BOLD}>
+            OpenCode
+          </text>
+          <text fg={colors.muted}>updated to</text>
+          <text fg={colors.accent}>{InstallationVersion}</text>
+        </box>
+      ))
+    },
+    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 = {
+  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<RGBA>, brightness: number) =>
+  palette[Math.round(Math.max(0, Math.min(1, brightness)) * rampSteps)]
+
+function Monogram() {
+  return (
+    <box flexDirection="column">
+      <For each={monogram}>
+        {(line) => (
+          <box flexDirection="row">
+            <For each={Array.from(line)}>
+              {(char) =>
+                char === "_" ? (
+                  <text bg={monogramShadow} selectable={false}>
+                    {" "}
+                  </text>
+                ) : (
+                  <text fg={monogramInk} selectable={false}>
+                    {char}
+                  </text>
+                )
+              }
+            </For>
+          </box>
+        )}
+      </For>
+    </box>
+  )
+}
+
+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 (
+    <box width="100%" height={4} flexDirection="row" gap={1}>
+      <Monogram />
+      <box flexDirection="column" flexGrow={1}>
+        <box flexDirection="row" gap={1}>
+          <text fg={colors.muted} attributes={TextAttributes.BOLD}>
+            OpenCode
+          </text>
+          <text fg={colors.muted}>is updating</text>
+          <Show when={props.from}>
+            <text fg={colors.muted}>from</text>
+            <text fg={colors.accentDim}>{props.from}</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>
+          <text fg={colors.muted}>
+            {props.active() + 1}/{stages.length}
+          </text>
+        </box>
+      </box>
+    </box>
+  )
+}
+
+export * as UpdatePreflight from "./update-preflight"

+ 7 - 2
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"]