mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 20:03:28 +00:00
397 lines
12 KiB
TypeScript
397 lines
12 KiB
TypeScript
// 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, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
|
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
|
|
import { monoSnapshot } from "./mono"
|
|
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
|
import { resolveRunTheme } from "./theme"
|
|
import type {
|
|
FooterApi,
|
|
FormCancel,
|
|
FormReply,
|
|
MiniSettingChange,
|
|
MiniSettings,
|
|
MiniHost,
|
|
PermissionReply,
|
|
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
|
|
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(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): 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 splashInfo(title: string | undefined, history: RunPrompt[]) {
|
|
if (title && !isFallbackTitle(title)) {
|
|
return {
|
|
title,
|
|
showSession: true,
|
|
}
|
|
}
|
|
|
|
const next = history.find((item) => item.text.trim().length > 0)
|
|
return {
|
|
title: next?.text ?? title,
|
|
showSession: !!next,
|
|
}
|
|
}
|
|
|
|
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 splash = splashInfo(input.sessionTitle, input.history)
|
|
const meta = splashMeta({
|
|
title: splash.title,
|
|
session_id: input.sessionID,
|
|
mono,
|
|
})
|
|
const wrote = queueSplash(
|
|
renderer,
|
|
state,
|
|
"entry",
|
|
miniSettings.splash === "show"
|
|
? entrySplash({
|
|
...meta,
|
|
theme: theme.splash,
|
|
showSession: splash.showSession,
|
|
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
|
|
mono,
|
|
})
|
|
: undefined,
|
|
)
|
|
await renderer.idle().catch(() => {})
|
|
|
|
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,
|
|
mono,
|
|
// The transcript always starts one row below the terminal's prior output,
|
|
// even when the entry splash itself is hidden.
|
|
wrote: wrote || miniSettings.splash === "hide",
|
|
tuiConfig,
|
|
miniSettings: {
|
|
current: miniSettings,
|
|
update: input.onMiniSettingChange,
|
|
},
|
|
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,
|
|
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 {
|
|
await footer.idle().catch(() => {})
|
|
|
|
if (!renderer.isDestroyed && next.showExit && footer.currentMiniSettings().splash === "show") {
|
|
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
|
|
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
|
|
wroteExit = queueSplash(
|
|
renderer,
|
|
state,
|
|
"exit",
|
|
exitSplash({
|
|
...splashMeta({
|
|
title: splash.title,
|
|
session_id: sessionID,
|
|
mono,
|
|
}),
|
|
theme: footer.currentTheme().splash,
|
|
mono,
|
|
}),
|
|
)
|
|
await renderer.idle().catch(() => {})
|
|
}
|
|
} finally {
|
|
footer.close()
|
|
await footer.idle().catch(() => {})
|
|
footer.destroy()
|
|
if (input.host.platform === "linux") renderer.setTerminalTitle("")
|
|
if (mono) renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
|
|
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(next) {
|
|
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 })
|
|
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history)
|
|
renderer.writeToScrollback(
|
|
entrySplash({
|
|
...splashMeta({
|
|
title: splash.title,
|
|
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
|
|
mono,
|
|
}),
|
|
theme: footer.currentTheme().splash,
|
|
showSession: splash.showSession,
|
|
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
|
|
mono,
|
|
}),
|
|
)
|
|
renderer.requestRender()
|
|
await renderer.idle().catch(() => {})
|
|
},
|
|
close,
|
|
}
|
|
}
|