update-preflight.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. /** @jsxImportSource @opentui/solid */
  2. // Split-footer status shown while a freshly launched CLI replaces a
  3. // version-mismatched background service before the TUI attaches.
  4. import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
  5. import { render, useTerminalDimensions } from "@opentui/solid"
  6. import { OPENCODE_VERSION } from "../version"
  7. import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
  8. import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
  9. import { go } from "@opencode-ai/tui/logo"
  10. import { setTimeout } from "node:timers/promises"
  11. import {
  12. batch,
  13. createEffect,
  14. createMemo,
  15. createSignal,
  16. For,
  17. Index,
  18. on,
  19. onCleanup,
  20. onMount,
  21. Show,
  22. untrack,
  23. } from "solid-js"
  24. const stages = ["Keeping your session safe", "Starting the new background service", "Loading OpenCode"] as const
  25. const stageFloor = 480
  26. const transitionDuration = 420
  27. const completionHold = 650
  28. export type Handle = {
  29. readonly begin: (from?: string) => boolean
  30. readonly loading: () => void
  31. readonly finish: () => Promise<Handoff | undefined>
  32. readonly fail: (message: string) => Promise<void>
  33. readonly close: () => Promise<void>
  34. }
  35. export type Handoff = {
  36. readonly renderer: CliRenderer
  37. readonly mode: ThemeMode | null
  38. readonly complete: () => void
  39. }
  40. export const make = (): Handle => {
  41. let session: Promise<Session | undefined> | undefined
  42. return {
  43. begin: (from) => {
  44. if (!process.stdout.isTTY || !process.stdin.isTTY) return false
  45. session ??= open(from).catch(() => {
  46. process.stderr.write("Restarting background server (version mismatch)...\n")
  47. return undefined
  48. })
  49. return true
  50. },
  51. loading: () => {
  52. void session?.then((active) => active?.loading())
  53. },
  54. finish: async () => {
  55. const active = await session
  56. return active?.finish()
  57. },
  58. fail: async (message) => {
  59. const active = await session
  60. await active?.fail(message)
  61. },
  62. close: async () => {
  63. const active = await session
  64. await active?.close()
  65. },
  66. }
  67. }
  68. type Session = {
  69. readonly loading: () => Promise<void>
  70. readonly finish: () => Promise<Handoff>
  71. readonly fail: (message: string) => Promise<void>
  72. readonly close: () => Promise<void>
  73. }
  74. async function open(from?: string): Promise<Session> {
  75. registerOpencodeSpinner()
  76. const [active, setActive] = createSignal(0)
  77. const [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running")
  78. const [failure, setFailure] = createSignal("")
  79. const [animating, setAnimating] = createSignal(true)
  80. const [visible, setVisible] = createSignal(true)
  81. let resolveOutcome: (() => void) | undefined
  82. const renderer = await createCliRenderer({
  83. stdin: process.stdin,
  84. useMouse: false,
  85. autoFocus: false,
  86. openConsoleOnError: false,
  87. exitOnCtrlC: false,
  88. screenMode: "split-footer",
  89. footerHeight: 4,
  90. targetFps: 60,
  91. useKittyKeyboard: {},
  92. consoleOptions: {
  93. keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
  94. },
  95. externalOutputMode: "capture-stdout",
  96. consoleMode: "disabled",
  97. })
  98. const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
  99. await render(
  100. () => (
  101. <Show when={visible()}>
  102. <UpdateFooter
  103. from={from}
  104. active={active}
  105. outcome={outcome}
  106. failure={failure}
  107. animating={animating}
  108. renderer={renderer}
  109. onOutcomeSettled={() => resolveOutcome?.()}
  110. />
  111. </Show>
  112. ),
  113. renderer,
  114. ).catch((error) => {
  115. if (!renderer.isDestroyed) renderer.destroy()
  116. throw error
  117. })
  118. let shownAt = performance.now()
  119. const waitForStage = async () => {
  120. const remaining = stageFloor - (performance.now() - shownAt)
  121. if (remaining > 0) await setTimeout(remaining)
  122. }
  123. const advance = async (stage: number) => {
  124. await waitForStage()
  125. if (outcome() !== "running") return
  126. setActive(stage)
  127. shownAt = performance.now()
  128. }
  129. // Service.start currently exposes only its start boundary, so this first
  130. // transition is time-based. Finer lifecycle callbacks remain follow-up work.
  131. const auto = advance(1)
  132. const transitionTo = async (next: "success" | "failure", hold: number) => {
  133. const settled = Promise.withResolvers<void>()
  134. resolveOutcome = settled.resolve
  135. setOutcome(next)
  136. const completed = await Promise.race([
  137. settled.promise.then(() => true),
  138. setTimeout(transitionDuration + 500).then(() => false),
  139. ])
  140. resolveOutcome = undefined
  141. setAnimating(false)
  142. if (completed) await setTimeout(hold)
  143. }
  144. let closing: Promise<void> | undefined
  145. let transferred = false
  146. const close = () =>
  147. (closing ??= (async () => {
  148. if (transferred) return
  149. setAnimating(false)
  150. if (renderer.isDestroyed) return
  151. renderer.pause()
  152. await Promise.race([renderer.idle(), setTimeout(500)])
  153. renderer.destroy()
  154. })())
  155. let loading: Promise<void> | undefined
  156. const load = () =>
  157. (loading ??= (async () => {
  158. await auto
  159. await advance(2)
  160. })())
  161. let settled: Promise<void> | undefined
  162. const settle = (task: () => Promise<void>) => (settled ??= task())
  163. return {
  164. loading: load,
  165. finish: async () => {
  166. await settle(async () => {
  167. await load()
  168. await waitForStage()
  169. await transitionTo("success", completionHold)
  170. })
  171. const mode = await terminalMode
  172. renderer.externalOutputMode = "passthrough"
  173. renderer.screenMode = "alternate-screen"
  174. renderer.consoleMode = "console-overlay"
  175. renderer.requestRender()
  176. await Promise.race([renderer.idle(), setTimeout(500)])
  177. transferred = true
  178. return {
  179. renderer,
  180. mode,
  181. complete: () => setVisible(false),
  182. }
  183. },
  184. fail: (message) =>
  185. settle(async () => {
  186. setFailure(message)
  187. await transitionTo("failure", 250)
  188. await close()
  189. }),
  190. close,
  191. }
  192. }
  193. const colors = {
  194. accent: RGBA.fromHex("#a6b8ff"),
  195. accentBright: RGBA.fromHex("#eef1ff"),
  196. accentDim: RGBA.fromHex("#596998"),
  197. error: RGBA.fromHex("#ff8192"),
  198. muted: RGBA.fromHex("#808080"),
  199. success: RGBA.fromHex("#8bd5a5"),
  200. text: RGBA.fromHex("#eeeeee"),
  201. }
  202. const monogram = go.right.slice(1)
  203. const sweepBlend = 8
  204. const textDim = RGBA.fromHex("#4c4c4c")
  205. const rampSteps = 32
  206. const blend = (from: RGBA, to: RGBA, amount: number) =>
  207. RGBA.fromValues(
  208. from.r + (to.r - from.r) * amount,
  209. from.g + (to.g - from.g) * amount,
  210. from.b + (to.b - from.b) * amount,
  211. )
  212. const ramp = (from: RGBA, to: RGBA) =>
  213. Array.from({ length: rampSteps + 1 }, (_, step) => blend(from, to, step / rampSteps))
  214. const railRamp = ramp(colors.accentDim, colors.accentBright)
  215. const monogramRamp = ramp(colors.muted, colors.accent)
  216. const rampCache = new Map<RGBA, ReadonlyArray<RGBA>>()
  217. const rampFor = (color: RGBA) => {
  218. const cached = rampCache.get(color)
  219. if (cached) return cached
  220. const result = ramp(textDim, color)
  221. rampCache.set(color, result)
  222. return result
  223. }
  224. const shade = (palette: ReadonlyArray<RGBA>, brightness: number) =>
  225. palette[Math.round(Math.max(0, Math.min(1, brightness)) * rampSteps)]
  226. type Cell = { readonly char: string; readonly color: RGBA; readonly bold?: boolean }
  227. const styled = (text: string, color: RGBA, bold?: boolean): Cell[] =>
  228. Array.from(text).map((char) => ({ char, color, bold }))
  229. const phrase = (...segments: ReadonlyArray<readonly [string, RGBA, boolean?]>): Cell[] =>
  230. segments.flatMap((segment, index) => [
  231. ...(index > 0 ? styled(" ", colors.muted) : []),
  232. ...styled(segment[0], segment[1], segment[2]),
  233. ])
  234. function Monogram(props: { ink: () => RGBA }) {
  235. const shadow = createMemo(() => {
  236. const ink = props.ink()
  237. return RGBA.fromValues(ink.r * 0.25, ink.g * 0.25, ink.b * 0.25)
  238. })
  239. return (
  240. <box flexDirection="column">
  241. <For each={monogram}>
  242. {(line) => (
  243. <box flexDirection="row">
  244. <For each={Array.from(line)}>
  245. {(char) =>
  246. char === "_" ? (
  247. <text bg={shadow()} selectable={false}>
  248. {" "}
  249. </text>
  250. ) : (
  251. <text fg={props.ink()} selectable={false}>
  252. {char}
  253. </text>
  254. )
  255. }
  256. </For>
  257. </box>
  258. )}
  259. </For>
  260. </box>
  261. )
  262. }
  263. type CellTransition = { from: Cell[]; to: Cell[]; done?: () => void }
  264. function createTransition(render: (transition: CellTransition, progress: number) => Cell[]) {
  265. const [state, setState] = createSignal<{ from: Cell[]; to: Cell[]; done?: () => void } | undefined>()
  266. const [progress, setProgress] = createSignal(0)
  267. let elapsed = 0
  268. const cells = createMemo(() => {
  269. const transition = state()
  270. if (!transition) return undefined
  271. return render(transition, progress())
  272. })
  273. return {
  274. start(from: Cell[], to: Cell[], done?: () => void) {
  275. elapsed = 0
  276. setProgress(0)
  277. setState({ from, to, done })
  278. },
  279. tick(deltaTime: number) {
  280. const transition = state()
  281. if (!transition) return
  282. elapsed = Math.min(transitionDuration, elapsed + deltaTime)
  283. setProgress(elapsed / transitionDuration)
  284. if (elapsed < transitionDuration) return
  285. setState(undefined)
  286. transition.done?.()
  287. },
  288. cells,
  289. progress,
  290. }
  291. }
  292. const createSweep = () =>
  293. createTransition((transition, progress) => {
  294. const length = Math.max(transition.from.length, transition.to.length)
  295. const front = smoothstep(progress) * (length + 2 * sweepBlend) - sweepBlend
  296. return Array.from({ length }, (_, index) => {
  297. const passed = Math.max(0, Math.min(1, (front - index) / sweepBlend))
  298. const brightness = smoothstep(Math.abs(passed * 2 - 1))
  299. const cell = (passed >= 0.5 ? transition.to[index] : transition.from[index]) ?? {
  300. char: " ",
  301. color: colors.text,
  302. }
  303. return { ...cell, color: shade(rampFor(cell.color), brightness) }
  304. })
  305. })
  306. const createFade = () =>
  307. createTransition((transition, progress) => {
  308. const entering = progress >= 0.5
  309. const brightness = smoothstep(entering ? progress * 2 - 1 : 1 - progress * 2)
  310. return (entering ? transition.to : transition.from).map((cell) => ({
  311. ...cell,
  312. color: shade(rampFor(cell.color), brightness),
  313. }))
  314. })
  315. const smoothstep = (value: number) => value * value * (3 - 2 * value)
  316. const frameDone = Promise.resolve()
  317. function UpdateFooter(props: {
  318. from?: string
  319. active: () => number
  320. outcome: () => "running" | "success" | "failure"
  321. failure: () => string
  322. animating: () => boolean
  323. renderer: CliRenderer
  324. onOutcomeSettled: () => void
  325. }) {
  326. const term = useTerminalDimensions()
  327. const [position, setPosition] = createSignal(0)
  328. const [pulse, setPulse] = createSignal(0)
  329. const headerFade = createFade()
  330. const statusSweep = createSweep()
  331. const runningHeader = () =>
  332. phrase(
  333. ["OpenCode", colors.muted, true],
  334. ["is updating", colors.muted],
  335. ...(props.from
  336. ? ([
  337. ["from", colors.muted],
  338. [props.from, colors.accentDim],
  339. ] as const)
  340. : []),
  341. ["to", colors.muted],
  342. [OPENCODE_VERSION, colors.accent],
  343. )
  344. const completedHeader = phrase(
  345. ["OpenCode", colors.muted, true],
  346. ["updated to", colors.muted],
  347. [OPENCODE_VERSION, colors.accent],
  348. )
  349. const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted])
  350. const outcomeStatus = () =>
  351. props.outcome() === "success"
  352. ? [...styled("✓", colors.success), ...styled(" Ready", colors.text)]
  353. : [...styled("!", colors.error), ...styled(" " + props.failure(), colors.text)]
  354. let previousStage: string = stages[0]
  355. createEffect(
  356. on(props.active, (index) => {
  357. if (props.outcome() !== "running") return
  358. const next = stages[index]
  359. if (next === previousStage) return
  360. statusSweep.start(styled(previousStage, colors.text), styled(next, colors.text))
  361. previousStage = next
  362. }),
  363. )
  364. createEffect(
  365. on(
  366. props.outcome,
  367. (outcome) => {
  368. if (outcome === "running") return
  369. const visibleStatus = untrack(statusSweep.cells) ?? styled(previousStage, colors.text)
  370. headerFade.start(runningHeader(), outcome === "success" ? completedHeader : pausedHeader)
  371. statusSweep.start([...styled(" ", colors.text), ...visibleStatus], outcomeStatus(), props.onOutcomeSettled)
  372. },
  373. { defer: true },
  374. ),
  375. )
  376. const header = createMemo(
  377. () =>
  378. headerFade.cells() ??
  379. (props.outcome() === "success"
  380. ? completedHeader
  381. : props.outcome() === "failure"
  382. ? pausedHeader
  383. : runningHeader()),
  384. )
  385. const monogramInk = createMemo(() =>
  386. props.outcome() === "success" ? shade(monogramRamp, smoothstep(headerFade.progress())) : colors.muted,
  387. )
  388. const rail = createMemo(() => {
  389. const width = Math.max(0, Math.min(30, term().width - 39))
  390. if (width === 0) return []
  391. const filled = Math.round(position() * width)
  392. const glowRadius = 6
  393. const span = Math.max(1, filled + glowRadius * 2)
  394. const center = pulse() * span - glowRadius
  395. const success = props.outcome() === "success"
  396. const completion = smoothstep(headerFade.progress())
  397. return Array.from({ length: width }, (_, index) => {
  398. const color =
  399. index >= filled
  400. ? colors.muted
  401. : shade(railRamp, Math.max(0, 1 - Math.abs(index - center) / glowRadius) ** 2)
  402. return {
  403. char: success || index < filled ? "━" : "·",
  404. color: success ? blend(color, colors.accent, completion) : color,
  405. }
  406. })
  407. })
  408. onMount(() => {
  409. let value = 0
  410. let velocity = 0
  411. let phase = 0
  412. const frame = (deltaTime: number) => {
  413. if (!props.animating()) return frameDone
  414. const elapsed = Math.min(0.032, deltaTime / 1_000)
  415. const stiffness = 110
  416. const damping = 2 * Math.sqrt(stiffness)
  417. const target = props.outcome() === "success" ? 1 : (props.active() + 1) / stages.length
  418. velocity += (stiffness * (target - value) - damping * velocity) * elapsed
  419. value += velocity * elapsed
  420. phase = (phase + deltaTime / 900) % 1
  421. batch(() => {
  422. setPosition(Math.max(0, Math.min(1, value)))
  423. setPulse(phase)
  424. })
  425. headerFade.tick(deltaTime)
  426. statusSweep.tick(deltaTime)
  427. return frameDone
  428. }
  429. props.renderer.setFrameCallback(frame)
  430. onCleanup(() => props.renderer.removeFrameCallback(frame))
  431. })
  432. return (
  433. <box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
  434. <Monogram ink={monogramInk} />
  435. <box flexDirection="column" flexGrow={1} overflow="hidden">
  436. <CellLine cells={header()} />
  437. <Show
  438. when={props.outcome() === "running"}
  439. fallback={<CellLine cells={statusSweep.cells() ?? outcomeStatus()} />}
  440. >
  441. <box flexDirection="row" gap={1}>
  442. <spinner frames={SPINNER_FRAMES} interval={80} color={colors.accent} />
  443. <CellLine cells={statusSweep.cells() ?? styled(stages[props.active()], colors.text)} />
  444. </box>
  445. </Show>
  446. <box flexDirection="row" gap={1}>
  447. <CellLine cells={rail()} />
  448. <text fg={colors.muted}>
  449. {props.outcome() === "success" ? stages.length : props.active() + 1}/{stages.length}
  450. </text>
  451. </box>
  452. </box>
  453. </box>
  454. )
  455. }
  456. function CellLine(props: { cells: ReadonlyArray<Cell> }) {
  457. return (
  458. <text truncate>
  459. <Index each={props.cells}>
  460. {(cell) => (
  461. <span
  462. style={{
  463. fg: cell().color,
  464. attributes: cell().bold ? TextAttributes.BOLD : TextAttributes.NONE,
  465. }}
  466. >
  467. {cell().char}
  468. </span>
  469. )}
  470. </Index>
  471. </text>
  472. )
  473. }
  474. export * as UpdatePreflight from "./update-preflight"