fix(cli): keep update preflight through TUI loading (#36475)
This commit is contained in:
parent
8e657c7db5
commit
e22e3b8f2c
3 changed files with 127 additions and 46 deletions
|
|
@ -17,6 +17,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.forkScoped)
|
||||
const preflight = UpdatePreflight.make()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||
const server = yield* Server.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
|
|
@ -33,7 +34,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() => preflight.finish())
|
||||
preflight.loading()
|
||||
const config = yield* TuiConfig.load()
|
||||
let disposeSlots: (() => void) | undefined
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
|
|
@ -41,6 +42,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||
server,
|
||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||
config,
|
||||
terminalHandoff: () => preflight.finish(),
|
||||
log: (level, message, tags) => {
|
||||
const effect =
|
||||
level === "debug"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/** @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 } from "@opentui/core"
|
||||
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
|
||||
import { render, useTerminalDimensions } from "@opentui/solid"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
|
||||
|
|
@ -21,15 +21,23 @@ import {
|
|||
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", "Loading OpenCode"] as const
|
||||
const stageFloor = 480
|
||||
const transitionDuration = 420
|
||||
const completionHold = 650
|
||||
|
||||
export type Handle = {
|
||||
readonly begin: (from?: string) => boolean
|
||||
readonly finish: () => Promise<void>
|
||||
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 => {
|
||||
|
|
@ -43,20 +51,29 @@ export const make = (): Handle => {
|
|||
})
|
||||
return true
|
||||
},
|
||||
loading: () => {
|
||||
void session?.then((active) => active?.loading())
|
||||
},
|
||||
finish: async () => {
|
||||
const active = await session
|
||||
await active?.finish()
|
||||
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 finish: () => Promise<void>
|
||||
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> {
|
||||
|
|
@ -65,6 +82,7 @@ async function open(from?: string): Promise<Session> {
|
|||
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,
|
||||
|
|
@ -72,25 +90,30 @@ async function open(from?: string): Promise<Session> {
|
|||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
exitOnCtrlC: false,
|
||||
exitSignals: [],
|
||||
screenMode: "split-footer",
|
||||
footerHeight: 4,
|
||||
targetFps: 60,
|
||||
useKittyKeyboard: {},
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
},
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
clearOnShutdown: false,
|
||||
})
|
||||
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
|
||||
await render(
|
||||
() => (
|
||||
<UpdateFooter
|
||||
from={from}
|
||||
active={active}
|
||||
outcome={outcome}
|
||||
failure={failure}
|
||||
animating={animating}
|
||||
renderer={renderer}
|
||||
onOutcomeSettled={() => resolveOutcome?.()}
|
||||
/>
|
||||
<Show when={visible()}>
|
||||
<UpdateFooter
|
||||
from={from}
|
||||
active={active}
|
||||
outcome={outcome}
|
||||
failure={failure}
|
||||
animating={animating}
|
||||
renderer={renderer}
|
||||
onOutcomeSettled={() => resolveOutcome?.()}
|
||||
/>
|
||||
</Show>
|
||||
),
|
||||
renderer,
|
||||
).catch((error) => {
|
||||
|
|
@ -98,9 +121,12 @@ async function open(from?: string): Promise<Session> {
|
|||
throw error
|
||||
})
|
||||
let shownAt = performance.now()
|
||||
const advance = async (stage: number) => {
|
||||
const waitForStage = async () => {
|
||||
const remaining = stageFloor - (performance.now() - shownAt)
|
||||
if (remaining > 0) await Bun.sleep(remaining)
|
||||
}
|
||||
const advance = async (stage: number) => {
|
||||
await waitForStage()
|
||||
if (outcome() !== "running") return
|
||||
setActive(stage)
|
||||
shownAt = performance.now()
|
||||
|
|
@ -120,30 +146,53 @@ async function open(from?: string): Promise<Session> {
|
|||
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 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(), Bun.sleep(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 {
|
||||
finish: () =>
|
||||
settle(async () => {
|
||||
await auto
|
||||
await advance(2)
|
||||
await Bun.sleep(stageFloor)
|
||||
loading: load,
|
||||
finish: async () => {
|
||||
await settle(async () => {
|
||||
await load()
|
||||
await waitForStage()
|
||||
await transitionTo("success", completionHold)
|
||||
await close()
|
||||
}),
|
||||
})
|
||||
const mode = await terminalMode
|
||||
renderer.externalOutputMode = "passthrough"
|
||||
renderer.screenMode = "alternate-screen"
|
||||
renderer.consoleMode = "console-overlay"
|
||||
renderer.requestRender()
|
||||
await Promise.race([renderer.idle(), Bun.sleep(500)])
|
||||
transferred = true
|
||||
return {
|
||||
renderer,
|
||||
mode,
|
||||
complete: () => setVisible(false),
|
||||
}
|
||||
},
|
||||
fail: (message) =>
|
||||
settle(async () => {
|
||||
setFailure(message)
|
||||
await transitionTo("failure", 250)
|
||||
await close()
|
||||
}),
|
||||
close,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -162,15 +211,14 @@ 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) => {
|
||||
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,
|
||||
)
|
||||
})
|
||||
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>>()
|
||||
|
|
@ -356,15 +404,21 @@ function UpdateFooter(props: {
|
|||
const rail = createMemo(() => {
|
||||
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)
|
||||
const center = pulse() * span - glowRadius
|
||||
const success = props.outcome() === "success"
|
||||
const completion = smoothstep(headerFade.progress())
|
||||
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) }
|
||||
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,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,14 @@ import { LogProvider, useLog, type LogSink } from "./context/log"
|
|||
import { ExitProvider, useExit } from "./context/exit"
|
||||
import { EpilogueProvider } from "./context/epilogue"
|
||||
import * as Selection from "./util/selection"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import {
|
||||
CliRenderEvents,
|
||||
createCliRenderer,
|
||||
MouseButton,
|
||||
type CliRenderer,
|
||||
type CliRendererConfig,
|
||||
type ThemeMode,
|
||||
} from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "./context/route"
|
||||
import {
|
||||
Switch,
|
||||
|
|
@ -150,6 +157,14 @@ export type TuiInput = {
|
|||
config: TuiConfig.Resolved
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
pluginHost: TuiPluginHost
|
||||
terminalHandoff?: () => Promise<
|
||||
| {
|
||||
readonly renderer: CliRenderer
|
||||
readonly mode: ThemeMode | null
|
||||
readonly complete: () => void
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
log?: LogSink
|
||||
}
|
||||
|
||||
|
|
@ -194,6 +209,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
Effect.map((response) => response.location.directory),
|
||||
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
|
||||
)
|
||||
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
|
||||
const reconnectEndpoint = input.server.reconnect
|
||||
const reconnect = reconnectEndpoint
|
||||
? async (attempt: number) => {
|
||||
|
|
@ -225,6 +241,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
},
|
||||
} satisfies CliRendererConfig
|
||||
|
||||
if (handoff) {
|
||||
handoff.renderer.useMouse = options.useMouse
|
||||
return handoff.renderer
|
||||
}
|
||||
|
||||
if (process.env.OPENCODE_DRIVE) {
|
||||
const { Drive } = await import("@opencode-ai/simulation/frontend")
|
||||
return Drive.create(options)
|
||||
|
|
@ -267,7 +288,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
yield* Effect.tryPromise(async () => {
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
const mode = handoff?.mode ?? (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
if (renderer.isDestroyed) return
|
||||
|
||||
await render(() => {
|
||||
|
|
@ -392,6 +413,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
</LogProvider>
|
||||
)
|
||||
}, renderer)
|
||||
if (handoff) {
|
||||
renderer.once(CliRenderEvents.FRAME, handoff.complete)
|
||||
renderer.requestRender()
|
||||
}
|
||||
})
|
||||
yield* Deferred.await(shutdown)
|
||||
return { epilogue: exit.epilogue, reason: exit.reason }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue