fix(cli): keep update preflight through TUI loading (#36475)
This commit is contained in:
@@ -17,6 +17,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
const updater = yield* Updater.Service
|
const updater = yield* Updater.Service
|
||||||
yield* updater.check().pipe(Effect.forkScoped)
|
yield* updater.check().pipe(Effect.forkScoped)
|
||||||
const preflight = UpdatePreflight.make()
|
const preflight = UpdatePreflight.make()
|
||||||
|
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||||
const server = yield* Server.resolve({
|
const server = yield* Server.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
standalone: input.standalone,
|
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")),
|
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()
|
const config = yield* TuiConfig.load()
|
||||||
let disposeSlots: (() => void) | undefined
|
let disposeSlots: (() => void) | undefined
|
||||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||||
@@ -41,6 +42,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
server,
|
server,
|
||||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||||
config,
|
config,
|
||||||
|
terminalHandoff: () => preflight.finish(),
|
||||||
log: (level, message, tags) => {
|
log: (level, message, tags) => {
|
||||||
const effect =
|
const effect =
|
||||||
level === "debug"
|
level === "debug"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
// Split-footer status shown while a freshly launched CLI replaces a
|
// Split-footer status shown while a freshly launched CLI replaces a
|
||||||
// version-mismatched background service before the TUI attaches.
|
// 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 { render, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
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"
|
||||||
@@ -21,15 +21,23 @@ import {
|
|||||||
untrack,
|
untrack,
|
||||||
} from "solid-js"
|
} 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 stageFloor = 480
|
||||||
const transitionDuration = 420
|
const transitionDuration = 420
|
||||||
const completionHold = 650
|
const completionHold = 650
|
||||||
|
|
||||||
export type Handle = {
|
export type Handle = {
|
||||||
readonly begin: (from?: string) => boolean
|
readonly begin: (from?: string) => boolean
|
||||||
readonly finish: () => Promise<void>
|
readonly loading: () => void
|
||||||
|
readonly finish: () => Promise<Handoff | undefined>
|
||||||
readonly fail: (message: string) => Promise<void>
|
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 => {
|
export const make = (): Handle => {
|
||||||
@@ -43,20 +51,29 @@ export const make = (): Handle => {
|
|||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
loading: () => {
|
||||||
|
void session?.then((active) => active?.loading())
|
||||||
|
},
|
||||||
finish: async () => {
|
finish: async () => {
|
||||||
const active = await session
|
const active = await session
|
||||||
await active?.finish()
|
return active?.finish()
|
||||||
},
|
},
|
||||||
fail: async (message) => {
|
fail: async (message) => {
|
||||||
const active = await session
|
const active = await session
|
||||||
await active?.fail(message)
|
await active?.fail(message)
|
||||||
},
|
},
|
||||||
|
close: async () => {
|
||||||
|
const active = await session
|
||||||
|
await active?.close()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Session = {
|
type Session = {
|
||||||
readonly finish: () => Promise<void>
|
readonly loading: () => Promise<void>
|
||||||
|
readonly finish: () => Promise<Handoff>
|
||||||
readonly fail: (message: string) => Promise<void>
|
readonly fail: (message: string) => Promise<void>
|
||||||
|
readonly close: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
async function open(from?: string): Promise<Session> {
|
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 [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running")
|
||||||
const [failure, setFailure] = createSignal("")
|
const [failure, setFailure] = createSignal("")
|
||||||
const [animating, setAnimating] = createSignal(true)
|
const [animating, setAnimating] = createSignal(true)
|
||||||
|
const [visible, setVisible] = createSignal(true)
|
||||||
let resolveOutcome: (() => void) | undefined
|
let resolveOutcome: (() => void) | undefined
|
||||||
const renderer = await createCliRenderer({
|
const renderer = await createCliRenderer({
|
||||||
stdin: process.stdin,
|
stdin: process.stdin,
|
||||||
@@ -72,16 +90,20 @@ async function open(from?: string): Promise<Session> {
|
|||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
openConsoleOnError: false,
|
openConsoleOnError: false,
|
||||||
exitOnCtrlC: false,
|
exitOnCtrlC: false,
|
||||||
exitSignals: [],
|
|
||||||
screenMode: "split-footer",
|
screenMode: "split-footer",
|
||||||
footerHeight: 4,
|
footerHeight: 4,
|
||||||
targetFps: 60,
|
targetFps: 60,
|
||||||
|
useKittyKeyboard: {},
|
||||||
|
consoleOptions: {
|
||||||
|
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||||
|
},
|
||||||
externalOutputMode: "capture-stdout",
|
externalOutputMode: "capture-stdout",
|
||||||
consoleMode: "disabled",
|
consoleMode: "disabled",
|
||||||
clearOnShutdown: false,
|
|
||||||
})
|
})
|
||||||
|
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
|
||||||
await render(
|
await render(
|
||||||
() => (
|
() => (
|
||||||
|
<Show when={visible()}>
|
||||||
<UpdateFooter
|
<UpdateFooter
|
||||||
from={from}
|
from={from}
|
||||||
active={active}
|
active={active}
|
||||||
@@ -91,6 +113,7 @@ async function open(from?: string): Promise<Session> {
|
|||||||
renderer={renderer}
|
renderer={renderer}
|
||||||
onOutcomeSettled={() => resolveOutcome?.()}
|
onOutcomeSettled={() => resolveOutcome?.()}
|
||||||
/>
|
/>
|
||||||
|
</Show>
|
||||||
),
|
),
|
||||||
renderer,
|
renderer,
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
@@ -98,9 +121,12 @@ async function open(from?: string): Promise<Session> {
|
|||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
let shownAt = performance.now()
|
let shownAt = performance.now()
|
||||||
const advance = async (stage: number) => {
|
const waitForStage = async () => {
|
||||||
const remaining = stageFloor - (performance.now() - shownAt)
|
const remaining = stageFloor - (performance.now() - shownAt)
|
||||||
if (remaining > 0) await Bun.sleep(remaining)
|
if (remaining > 0) await Bun.sleep(remaining)
|
||||||
|
}
|
||||||
|
const advance = async (stage: number) => {
|
||||||
|
await waitForStage()
|
||||||
if (outcome() !== "running") return
|
if (outcome() !== "running") return
|
||||||
setActive(stage)
|
setActive(stage)
|
||||||
shownAt = performance.now()
|
shownAt = performance.now()
|
||||||
@@ -120,30 +146,53 @@ async function open(from?: string): Promise<Session> {
|
|||||||
setAnimating(false)
|
setAnimating(false)
|
||||||
if (completed) await Bun.sleep(hold)
|
if (completed) await Bun.sleep(hold)
|
||||||
}
|
}
|
||||||
const close = async () => {
|
let closing: Promise<void> | undefined
|
||||||
|
let transferred = false
|
||||||
|
const close = () =>
|
||||||
|
(closing ??= (async () => {
|
||||||
|
if (transferred) return
|
||||||
setAnimating(false)
|
setAnimating(false)
|
||||||
if (renderer.isDestroyed) return
|
if (renderer.isDestroyed) return
|
||||||
renderer.pause()
|
renderer.pause()
|
||||||
await Promise.race([renderer.idle(), Bun.sleep(500)])
|
await Promise.race([renderer.idle(), Bun.sleep(500)])
|
||||||
renderer.destroy()
|
renderer.destroy()
|
||||||
}
|
})())
|
||||||
|
let loading: Promise<void> | undefined
|
||||||
|
const load = () =>
|
||||||
|
(loading ??= (async () => {
|
||||||
|
await auto
|
||||||
|
await advance(2)
|
||||||
|
})())
|
||||||
let settled: Promise<void> | undefined
|
let settled: Promise<void> | undefined
|
||||||
const settle = (task: () => Promise<void>) => (settled ??= task())
|
const settle = (task: () => Promise<void>) => (settled ??= task())
|
||||||
return {
|
return {
|
||||||
finish: () =>
|
loading: load,
|
||||||
settle(async () => {
|
finish: async () => {
|
||||||
await auto
|
await settle(async () => {
|
||||||
await advance(2)
|
await load()
|
||||||
await Bun.sleep(stageFloor)
|
await waitForStage()
|
||||||
await transitionTo("success", completionHold)
|
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) =>
|
fail: (message) =>
|
||||||
settle(async () => {
|
settle(async () => {
|
||||||
setFailure(message)
|
setFailure(message)
|
||||||
await transitionTo("failure", 250)
|
await transitionTo("failure", 250)
|
||||||
await close()
|
await close()
|
||||||
}),
|
}),
|
||||||
|
close,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,15 +211,14 @@ const sweepBlend = 8
|
|||||||
const textDim = RGBA.fromHex("#4c4c4c")
|
const textDim = RGBA.fromHex("#4c4c4c")
|
||||||
const rampSteps = 32
|
const rampSteps = 32
|
||||||
|
|
||||||
const ramp = (from: RGBA, to: RGBA) =>
|
const blend = (from: RGBA, to: RGBA, amount: number) =>
|
||||||
Array.from({ length: rampSteps + 1 }, (_, step) => {
|
RGBA.fromValues(
|
||||||
const amount = step / rampSteps
|
|
||||||
return RGBA.fromValues(
|
|
||||||
from.r + (to.r - from.r) * amount,
|
from.r + (to.r - from.r) * amount,
|
||||||
from.g + (to.g - from.g) * amount,
|
from.g + (to.g - from.g) * amount,
|
||||||
from.b + (to.b - from.b) * 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 railRamp = ramp(colors.accentDim, colors.accentBright)
|
||||||
const monogramRamp = ramp(colors.muted, colors.accent)
|
const monogramRamp = ramp(colors.muted, colors.accent)
|
||||||
const rampCache = new Map<RGBA, ReadonlyArray<RGBA>>()
|
const rampCache = new Map<RGBA, ReadonlyArray<RGBA>>()
|
||||||
@@ -356,15 +404,21 @@ function UpdateFooter(props: {
|
|||||||
const rail = createMemo(() => {
|
const rail = createMemo(() => {
|
||||||
const width = Math.max(0, Math.min(30, term().width - 39))
|
const width = Math.max(0, Math.min(30, term().width - 39))
|
||||||
if (width === 0) return []
|
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)
|
||||||
const center = pulse() * span - glowRadius
|
const center = pulse() * span - glowRadius
|
||||||
|
const success = props.outcome() === "success"
|
||||||
|
const completion = smoothstep(headerFade.progress())
|
||||||
return Array.from({ length: width }, (_, index) => {
|
return Array.from({ length: width }, (_, index) => {
|
||||||
if (index >= filled) return { char: "·", color: colors.muted }
|
const color =
|
||||||
const glow = Math.max(0, 1 - Math.abs(index - center) / glowRadius) ** 2
|
index >= filled
|
||||||
return { char: "━", color: shade(railRamp, glow) }
|
? 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 { ExitProvider, useExit } from "./context/exit"
|
||||||
import { EpilogueProvider } from "./context/epilogue"
|
import { EpilogueProvider } from "./context/epilogue"
|
||||||
import * as Selection from "./util/selection"
|
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 { RouteProvider, useRoute } from "./context/route"
|
||||||
import {
|
import {
|
||||||
Switch,
|
Switch,
|
||||||
@@ -150,6 +157,14 @@ export type TuiInput = {
|
|||||||
config: TuiConfig.Resolved
|
config: TuiConfig.Resolved
|
||||||
onSnapshot?: () => Promise<string[]>
|
onSnapshot?: () => Promise<string[]>
|
||||||
pluginHost: TuiPluginHost
|
pluginHost: TuiPluginHost
|
||||||
|
terminalHandoff?: () => Promise<
|
||||||
|
| {
|
||||||
|
readonly renderer: CliRenderer
|
||||||
|
readonly mode: ThemeMode | null
|
||||||
|
readonly complete: () => void
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
>
|
||||||
log?: LogSink
|
log?: LogSink
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +209,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
Effect.map((response) => response.location.directory),
|
Effect.map((response) => response.location.directory),
|
||||||
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.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 reconnectEndpoint = input.server.reconnect
|
||||||
const reconnect = reconnectEndpoint
|
const reconnect = reconnectEndpoint
|
||||||
? async (attempt: number) => {
|
? async (attempt: number) => {
|
||||||
@@ -225,6 +241,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
},
|
},
|
||||||
} satisfies CliRendererConfig
|
} satisfies CliRendererConfig
|
||||||
|
|
||||||
|
if (handoff) {
|
||||||
|
handoff.renderer.useMouse = options.useMouse
|
||||||
|
return handoff.renderer
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.OPENCODE_DRIVE) {
|
if (process.env.OPENCODE_DRIVE) {
|
||||||
const { Drive } = await import("@opencode-ai/simulation/frontend")
|
const { Drive } = await import("@opencode-ai/simulation/frontend")
|
||||||
return Drive.create(options)
|
return Drive.create(options)
|
||||||
@@ -267,7 +288,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
yield* Effect.tryPromise(async () => {
|
yield* Effect.tryPromise(async () => {
|
||||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
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
|
if (renderer.isDestroyed) return
|
||||||
|
|
||||||
await render(() => {
|
await render(() => {
|
||||||
@@ -392,6 +413,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
</LogProvider>
|
</LogProvider>
|
||||||
)
|
)
|
||||||
}, renderer)
|
}, renderer)
|
||||||
|
if (handoff) {
|
||||||
|
renderer.once(CliRenderEvents.FRAME, handoff.complete)
|
||||||
|
renderer.requestRender()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
yield* Deferred.await(shutdown)
|
yield* Deferred.await(shutdown)
|
||||||
return { epilogue: exit.epilogue, reason: exit.reason }
|
return { epilogue: exit.epilogue, reason: exit.reason }
|
||||||
|
|||||||
Reference in New Issue
Block a user