diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 8ecc1b891f..d7b5921628 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -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" diff --git a/packages/cli/src/services/update-preflight.tsx b/packages/cli/src/services/update-preflight.tsx index 1be9d1638d..e471e798b0 100644 --- a/packages/cli/src/services/update-preflight.tsx +++ b/packages/cli/src/services/update-preflight.tsx @@ -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 + readonly loading: () => void + readonly finish: () => Promise readonly fail: (message: string) => Promise + readonly close: () => Promise +} + +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 + readonly loading: () => Promise + readonly finish: () => Promise readonly fail: (message: string) => Promise + readonly close: () => Promise } async function open(from?: string): Promise { @@ -65,6 +82,7 @@ async function open(from?: string): Promise { 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 { 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( () => ( - resolveOutcome?.()} - /> + + resolveOutcome?.()} + /> + ), renderer, ).catch((error) => { @@ -98,9 +121,12 @@ async function open(from?: string): Promise { 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 { 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 | 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 | undefined + const load = () => + (loading ??= (async () => { + await auto + await advance(2) + })()) let settled: Promise | undefined const settle = (task: () => Promise) => (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>() @@ -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, + } }) }) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index edd96f7a9b..1b291535c9 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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 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) { ) }, renderer) + if (handoff) { + renderer.once(CliRenderEvents.FRAME, handoff.complete) + renderer.requestRender() + } }) yield* Deferred.await(shutdown) return { epilogue: exit.epilogue, reason: exit.reason }