| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398 |
- // 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 { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
- import { Global } from "@opencode-ai/core/global"
- import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
- import { isDefaultTitle } from "@opencode-ai/tui/util/session"
- import { Locale } from "@opencode-ai/tui/util/locale"
- import { resolveInteractiveStdin } from "./runtime.stdin"
- import { entrySplash, exitSplash, splashMeta } from "./splash"
- import { resolveRunTheme } from "./theme"
- import type {
- FooterApi,
- PermissionReply,
- QuestionReject,
- QuestionReply,
- RunAgent,
- RunInput,
- RunPrompt,
- RunReference,
- RunTuiConfig,
- } from "./types"
- 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[]
- }
- type FooterLabels = {
- agentLabel: string
- modelLabel: string
- }
- export type LifecycleInput = {
- directory: 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>
- onPermissionReply: (input: PermissionReply) => void | Promise<void>
- onQuestionReply: (input: QuestionReply) => void | Promise<void>
- onQuestionReject: (input: QuestionReject) => void | Promise<void>
- onCycleVariant?: () => CycleResult | 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
- 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 && !isDefaultTitle(title)) {
- return {
- title,
- showSession: true,
- }
- }
- const next = history.find((item) => item.text.trim().length > 0)
- return {
- title: next?.text ?? title,
- showSession: !!next,
- }
- }
- function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
- const agentLabel = Locale.titlecase(input.agent ?? "build")
- return {
- agentLabel,
- modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "",
- }
- }
- function directoryLabel(directory: string) {
- const resolved = path.resolve(directory)
- const display =
- resolved === Global.Path.home
- ? "~"
- : resolved.startsWith(`${Global.Path.home}${path.sep}`)
- ? resolved.replace(Global.Path.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 source = resolveInteractiveStdin()
- const footerTask = import("./footer")
- let unregisterKeymap: (() => void) | undefined
- try {
- const renderer = await createCliRenderer({
- stdin: source.stdin,
- targetFps: 30,
- maxFps: 60,
- useMouse: false,
- autoFocus: false,
- openConsoleOnError: false,
- exitOnCtrlC: false,
- useKittyKeyboard: { events: process.platform === "win32" },
- screenMode: "split-footer",
- footerHeight: FOOTER_HEIGHT,
- externalOutputMode: "capture-stdout",
- consoleMode: "disabled",
- clearOnShutdown: false,
- })
- const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
- renderer.setBackgroundColor(theme.background)
- const keymap = createDefaultOpenTuiKeymap(renderer)
- unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
- const state: SplashState = {
- entry: false,
- exit: false,
- }
- const splash = splashInfo(input.sessionTitle, input.history)
- const meta = splashMeta({
- title: splash.title,
- session_id: input.sessionID,
- })
- const labels = footerLabels({
- agent: input.agent,
- model: input.model,
- variant: input.variant,
- })
- const wrote = queueSplash(
- renderer,
- state,
- "entry",
- entrySplash({
- ...meta,
- theme: theme.splash,
- showSession: splash.showSession,
- detail: directoryLabel(input.directory),
- }),
- )
- await renderer.idle().catch(() => {})
- const { RunFooter } = await footerTask
- let closed = false
- let sigintRegistered = false
- const footer = new RunFooter(renderer, {
- directory: input.directory,
- findFiles: input.findFiles,
- agents: input.agents,
- references: input.references,
- sessionID: input.getSessionID ?? (() => input.sessionID),
- ...labels,
- model: input.model,
- variant: input.variant,
- first: input.first,
- history: input.history,
- theme,
- wrote,
- keymap,
- tuiConfig,
- diffStyle: tuiConfig.diff_style ?? "auto",
- onPermissionReply: input.onPermissionReply,
- onQuestionReply: input.onQuestionReply,
- onQuestionReject: input.onQuestionReject,
- onCycleVariant: input.onCycleVariant,
- onModelSelect: input.onModelSelect,
- onVariantSelect: input.onVariantSelect,
- onInterrupt: input.onInterrupt,
- onBackground: input.onBackground,
- onEditorOpen: async ({ value }) => {
- if (closed || renderer.isDestroyed) {
- return
- }
- const { openEditor } = await import("@opencode-ai/tui/editor")
- await renderer.idle().catch(() => {})
- const ignore = () => {}
- detachSigint()
- process.on("SIGINT", ignore)
- try {
- return await openEditor({
- value,
- cwd: input.directory,
- renderer,
- stdin: source.stdin,
- })
- } finally {
- process.off("SIGINT", ignore)
- attachSigint()
- }
- },
- onSubagentSelect: input.onSubagentSelect,
- onSubagentInterrupt: input.onSubagentInterrupt,
- })
- const sigint = () => {
- footer.requestExit()
- }
- const attachSigint = () => {
- if (closed || sigintRegistered) {
- return
- }
- process.on("SIGINT", sigint)
- sigintRegistered = true
- }
- const detachSigint = () => {
- if (!sigintRegistered) {
- return
- }
- process.off("SIGINT", sigint)
- 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(() => {})
- const show = renderer.isDestroyed ? false : next.showExit
- if (!renderer.isDestroyed && 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,
- }),
- theme: footer.currentTheme().splash,
- }),
- )
- await renderer.idle().catch(() => {})
- }
- } finally {
- footer.close()
- await footer.idle().catch(() => {})
- footer.destroy()
- unregisterKeymap?.()
- shutdown(renderer)
- if (!wroteExit) {
- process.stdout.write("\n")
- }
- source.cleanup?.()
- }
- }
- return {
- footer,
- refreshTheme() {
- footer.refreshTheme()
- },
- 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,
- }),
- theme: footer.currentTheme().splash,
- showSession: splash.showSession,
- detail: directoryLabel(input.directory),
- }),
- )
- renderer.requestRender()
- },
- close,
- }
- } catch (error) {
- unregisterKeymap?.()
- source.cleanup?.()
- throw error
- }
- }
|