Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392// Lifecycle management for the split-footer renderer.//// Creates the OpenTUI CliRenderer in split-footer mode, resolves the theme// from the terminal palette, writes the entry splash to scrollback, and// constructs the RunFooter. Returns a Lifecycle handle whose close() writes// the exit splash and tears everything down in the right order:// footer.close → footer.destroy → renderer shutdown.//// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls// back to the usual two-press exit sequence through RunFooter.requestExit().import path from "path"import { CliRenderEvents, buildKittyKeyboardFlags, createCliRenderer, type CliRenderer, type ScrollbackWriter,} from "@opentui/core"import { isFallbackTitle } from "@opencode/util/session-title-fallback"import { monoSnapshot } from "./mono"import { entrySplash, exitSplash } from "./splash"import { resolveRunTheme } from "./theme"import type { FooterApi, FormCancel, FormReply, MiniSettingChange, MiniSettings, MiniHost, PermissionReply, QueuedPromptAction, RunAgent, RunInput, RunPrompt, RunReference, RunTuiConfig,} from "./types"import { resolveMiniSettings } from "./runtime.boot"import { formatModelLabel } from "./variant.shared"
const FOOTER_HEIGHT = 4
type SplashState = { entry: boolean exit: boolean}
type CycleResult = { modelLabel?: string status?: string variant?: string | undefined variants?: string[]}
export type LifecycleInput = { host: MiniHost getDirectory: () => string findFiles: (query: string) => Promise<string[]> agents: RunAgent[] references: RunReference[] sessionID: string sessionTitle?: string getSessionID?: () => string | undefined first: boolean history: RunPrompt[] agent: string | undefined model: RunInput["model"] variant: string | undefined tuiConfig: RunTuiConfig | Promise<RunTuiConfig> onMiniSettingChange?: (change: MiniSettingChange) => Promise<MiniSettings> onPermissionReply: (input: PermissionReply) => void | Promise<void> onFormReply: (input: FormReply) => void | Promise<void> onFormCancel: (input: FormCancel) => void | Promise<void> onCycleVariant?: () => CycleResult | void onAgentSelect?: (agent: string) => void onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void> onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void> onInterrupt?: () => void onBackground?: () => void onQueuedPromptAction?: (action: QueuedPromptAction, inboxID: string) => Promise<void> onSubagentSelect?: (sessionID: string | undefined) => void onSubagentInterrupt?: (sessionID: string) => void}
export type Lifecycle = { footer: FooterApi onResize(fn: () => void): () => void refreshTheme(): void setTitle(title?: string): void resetForReplay(): Promise<void> close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>}
// Gracefully tears down the renderer. Order matters: switch external output// back to passthrough before leaving split-footer mode, so pending stdout// doesn't get captured into the now-dead scrollback pipeline.function shutdown(renderer: CliRenderer): void { if (renderer.isDestroyed) { return }
if (renderer.externalOutputMode === "capture-stdout") { renderer.externalOutputMode = "passthrough" }
if (renderer.screenMode === "split-footer") { renderer.screenMode = "main-screen" }
if (!renderer.isDestroyed) { renderer.destroy() }}
function splashTitle(title: string | undefined, history: RunPrompt[]) { if (title && !isFallbackTitle(title)) return title return history.find((item) => item.text.trim().length > 0)?.text ?? title}
function directoryLabel(directory: string, home: string) { const resolved = path.resolve(directory) const display = resolved === home ? "~" : resolved.startsWith(`${home}${path.sep}`) ? resolved.replace(home, "~") : resolved return display.replaceAll("\\", "/")}
function queueSplash( renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">, state: SplashState, phase: keyof SplashState, write: ScrollbackWriter | undefined,): boolean { if (state[phase]) { return false }
if (!write) { return false }
state[phase] = true renderer.writeToScrollback(write) renderer.requestRender() return true}
// Boots the split-footer renderer and constructs the RunFooter.//// The renderer starts in split-footer mode with captured stdout so that// scrollback commits and footer repaints happen in the same frame. After// the entry splash, RunFooter takes over the footer region.export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> { const footerTask = import("./footer") const tuiConfig = await input.tuiConfig const miniSettings = resolveMiniSettings(tuiConfig) const mono = miniSettings.mono const renderer = await createCliRenderer({ stdin: input.host.terminal.stdin, targetFps: 30, maxFps: 60, useMouse: false, autoFocus: false, openConsoleOnError: false, exitOnCtrlC: false, useKittyKeyboard: mono ? { disambiguate: false, alternateKeys: false, events: false, allKeysAsEscapes: false, reportText: false } : { events: input.host.platform === "win32" }, screenMode: "split-footer", footerHeight: FOOTER_HEIGHT, externalOutputMode: "capture-stdout", consoleMode: "disabled", clearOnShutdown: false, }) if (mono) renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot) const setTitle = (title?: string) => { if (input.host.platform !== "linux") return if (!title || isFallbackTitle(title)) return renderer.setTerminalTitle("OpenCode") renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "…" : title}`) } setTitle(input.sessionTitle) const theme = await resolveRunTheme(renderer, tuiConfig.theme, mono) renderer.setBackgroundColor(theme.background) const state: SplashState = { entry: false, exit: false, } const startup = miniSettings.splash === "show" && !mono && tuiConfig.animations !== false ? { version: input.host.version, detail: directoryLabel(input.getDirectory(), input.host.paths.home) } : undefined queueSplash( renderer, state, "entry", miniSettings.splash === "show" && !startup ? entrySplash({ version: input.host.version, theme: theme.splash, detail: directoryLabel(input.getDirectory(), input.host.paths.home), mono, }) : undefined, ) await renderer.idle().catch(() => {}) if (mono) renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
const { RunFooter } = await footerTask let closed = false let sigintRegistered = false let detachSigintListener: (() => void) | undefined
const footer = new RunFooter(renderer, { directory: input.getDirectory, findFiles: input.findFiles, agents: input.agents, references: input.references, agent: input.agent, modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "Default model", model: input.model, variant: input.variant, first: input.first, history: input.history, theme, // The transcript always starts one row below the terminal's prior output, // even when the entry splash itself is hidden. wrote: true, startup, tuiConfig, miniSettings: { current: miniSettings, update: input.onMiniSettingChange, }, onMonoChange: (mono) => { if (mono) { renderer.disableKittyKeyboard() return } renderer.enableKittyKeyboard(buildKittyKeyboardFlags({ events: input.host.platform === "win32" })) }, onPermissionReply: input.onPermissionReply, onFormReply: input.onFormReply, onFormCancel: input.onFormCancel, onCycleVariant: input.onCycleVariant, onAgentSelect: input.onAgentSelect, onModelSelect: input.onModelSelect, onVariantSelect: input.onVariantSelect, onInterrupt: input.onInterrupt, onBackground: input.onBackground, onQueuedPromptAction: input.onQueuedPromptAction, onEditorOpen: async ({ value }) => { if (closed || renderer.isDestroyed) { return }
await renderer.idle().catch(() => {}) detachSigint() const detachIgnore = input.host.signals.sigint.subscribe(() => {}) try { return await input.host.editor.open({ value, cwd: input.getDirectory(), renderer, stdin: input.host.terminal.stdin, }) } finally { detachIgnore() attachSigint() } }, subscribeThemeSignal: input.host.signals.sigusr2.subscribe, onSubagentSelect: input.onSubagentSelect, onSubagentInterrupt: input.onSubagentInterrupt, })
const sigint = () => { footer.requestExit() }
const attachSigint = () => { if (closed || sigintRegistered) { return }
detachSigintListener = input.host.signals.sigint.subscribe(sigint) sigintRegistered = true }
const detachSigint = () => { if (!sigintRegistered) { return }
detachSigintListener?.() detachSigintListener = undefined sigintRegistered = false }
attachSigint()
const close = async (next: { showExit: boolean sessionTitle?: string sessionID?: string history?: RunPrompt[] }) => { if (closed) { return }
closed = true detachSigint() let wroteExit = false
try { footer.finishStartup() await footer.idle().catch(() => {})
if (!renderer.isDestroyed && next.showExit && footer.currentMiniSettings().splash === "show") { const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID wroteExit = queueSplash( renderer, state, "exit", exitSplash({ title: splashTitle(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history), session_id: sessionID, theme: footer.currentTheme().splash, mono: footer.currentMiniSettings().mono, }), ) await renderer.idle().catch(() => {}) } } finally { footer.close() await footer.idle().catch(() => {}) footer.destroy() if (input.host.platform === "linux") renderer.setTerminalTitle("") shutdown(renderer) if (!wroteExit) { input.host.stdout.write("\n") } } }
return { footer, refreshTheme() { footer.refreshTheme() }, setTitle, onResize(fn) { let width = renderer.terminalWidth let height = renderer.terminalHeight const resize = () => { if (width === renderer.terminalWidth && height === renderer.terminalHeight) { return }
width = renderer.terminalWidth height = renderer.terminalHeight fn() } renderer.on(CliRenderEvents.RESIZE, resize) return () => renderer.off(CliRenderEvents.RESIZE, resize) }, async resetForReplay() { if (closed || renderer.isDestroyed || footer.isClosed) { throw new Error("runtime closed") }
await footer.idle() if (closed || renderer.isDestroyed || footer.isClosed) { throw new Error("runtime closed") }
footer.resetForReplay(true) renderer.resetSplitFooterForReplay({ clearSavedLines: true }) renderer.writeToScrollback( entrySplash({ version: input.host.version, theme: footer.currentTheme().splash, detail: directoryLabel(input.getDirectory(), input.host.paths.home), mono: footer.currentMiniSettings().mono, }), ) renderer.requestRender() await renderer.idle().catch(() => {}) }, close, }}