runtime.lifecycle.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. // Lifecycle management for the split-footer renderer.
  2. //
  3. // Creates the OpenTUI CliRenderer in split-footer mode, resolves the theme
  4. // from the terminal palette, writes the entry splash to scrollback, and
  5. // constructs the RunFooter. Returns a Lifecycle handle whose close() writes
  6. // the exit splash and tears everything down in the right order:
  7. // footer.close → footer.destroy → renderer shutdown.
  8. //
  9. // Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
  10. // back to the usual two-press exit sequence through RunFooter.requestExit().
  11. import path from "path"
  12. import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
  13. import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
  14. import { Global } from "@opencode-ai/core/global"
  15. import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
  16. import { isDefaultTitle } from "@opencode-ai/tui/util/session"
  17. import { Locale } from "@opencode-ai/tui/util/locale"
  18. import { resolveInteractiveStdin } from "./runtime.stdin"
  19. import { entrySplash, exitSplash, splashMeta } from "./splash"
  20. import { resolveRunTheme } from "./theme"
  21. import type {
  22. FooterApi,
  23. PermissionReply,
  24. QuestionReject,
  25. QuestionReply,
  26. RunAgent,
  27. RunInput,
  28. RunPrompt,
  29. RunReference,
  30. RunTuiConfig,
  31. } from "./types"
  32. import { formatModelLabel } from "./variant.shared"
  33. const FOOTER_HEIGHT = 4
  34. type SplashState = {
  35. entry: boolean
  36. exit: boolean
  37. }
  38. type CycleResult = {
  39. modelLabel?: string
  40. status?: string
  41. variant?: string | undefined
  42. variants?: string[]
  43. }
  44. type FooterLabels = {
  45. agentLabel: string
  46. modelLabel: string
  47. }
  48. export type LifecycleInput = {
  49. directory: string
  50. findFiles: (query: string) => Promise<string[]>
  51. agents: RunAgent[]
  52. references: RunReference[]
  53. sessionID: string
  54. sessionTitle?: string
  55. getSessionID?: () => string | undefined
  56. first: boolean
  57. history: RunPrompt[]
  58. agent: string | undefined
  59. model: RunInput["model"]
  60. variant: string | undefined
  61. tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
  62. onPermissionReply: (input: PermissionReply) => void | Promise<void>
  63. onQuestionReply: (input: QuestionReply) => void | Promise<void>
  64. onQuestionReject: (input: QuestionReject) => void | Promise<void>
  65. onCycleVariant?: () => CycleResult | void
  66. onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
  67. onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
  68. onInterrupt?: () => void
  69. onBackground?: () => void
  70. onSubagentSelect?: (sessionID: string | undefined) => void
  71. onSubagentInterrupt?: (sessionID: string) => void
  72. }
  73. export type Lifecycle = {
  74. footer: FooterApi
  75. onResize(fn: () => void): () => void
  76. refreshTheme(): void
  77. resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
  78. close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
  79. }
  80. // Gracefully tears down the renderer. Order matters: switch external output
  81. // back to passthrough before leaving split-footer mode, so pending stdout
  82. // doesn't get captured into the now-dead scrollback pipeline.
  83. function shutdown(renderer: CliRenderer): void {
  84. if (renderer.isDestroyed) {
  85. return
  86. }
  87. if (renderer.externalOutputMode === "capture-stdout") {
  88. renderer.externalOutputMode = "passthrough"
  89. }
  90. if (renderer.screenMode === "split-footer") {
  91. renderer.screenMode = "main-screen"
  92. }
  93. if (!renderer.isDestroyed) {
  94. renderer.destroy()
  95. }
  96. }
  97. function splashInfo(title: string | undefined, history: RunPrompt[]) {
  98. if (title && !isDefaultTitle(title)) {
  99. return {
  100. title,
  101. showSession: true,
  102. }
  103. }
  104. const next = history.find((item) => item.text.trim().length > 0)
  105. return {
  106. title: next?.text ?? title,
  107. showSession: !!next,
  108. }
  109. }
  110. function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
  111. const agentLabel = Locale.titlecase(input.agent ?? "build")
  112. return {
  113. agentLabel,
  114. modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "",
  115. }
  116. }
  117. function directoryLabel(directory: string) {
  118. const resolved = path.resolve(directory)
  119. const display =
  120. resolved === Global.Path.home
  121. ? "~"
  122. : resolved.startsWith(`${Global.Path.home}${path.sep}`)
  123. ? resolved.replace(Global.Path.home, "~")
  124. : resolved
  125. return display.replaceAll("\\", "/")
  126. }
  127. function queueSplash(
  128. renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
  129. state: SplashState,
  130. phase: keyof SplashState,
  131. write: ScrollbackWriter | undefined,
  132. ): boolean {
  133. if (state[phase]) {
  134. return false
  135. }
  136. if (!write) {
  137. return false
  138. }
  139. state[phase] = true
  140. renderer.writeToScrollback(write)
  141. renderer.requestRender()
  142. return true
  143. }
  144. // Boots the split-footer renderer and constructs the RunFooter.
  145. //
  146. // The renderer starts in split-footer mode with captured stdout so that
  147. // scrollback commits and footer repaints happen in the same frame. After
  148. // the entry splash, RunFooter takes over the footer region.
  149. export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
  150. const source = resolveInteractiveStdin()
  151. const footerTask = import("./footer")
  152. let unregisterKeymap: (() => void) | undefined
  153. try {
  154. const renderer = await createCliRenderer({
  155. stdin: source.stdin,
  156. targetFps: 30,
  157. maxFps: 60,
  158. useMouse: false,
  159. autoFocus: false,
  160. openConsoleOnError: false,
  161. exitOnCtrlC: false,
  162. useKittyKeyboard: { events: process.platform === "win32" },
  163. screenMode: "split-footer",
  164. footerHeight: FOOTER_HEIGHT,
  165. externalOutputMode: "capture-stdout",
  166. consoleMode: "disabled",
  167. clearOnShutdown: false,
  168. })
  169. const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
  170. renderer.setBackgroundColor(theme.background)
  171. const keymap = createDefaultOpenTuiKeymap(renderer)
  172. unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
  173. const state: SplashState = {
  174. entry: false,
  175. exit: false,
  176. }
  177. const splash = splashInfo(input.sessionTitle, input.history)
  178. const meta = splashMeta({
  179. title: splash.title,
  180. session_id: input.sessionID,
  181. })
  182. const labels = footerLabels({
  183. agent: input.agent,
  184. model: input.model,
  185. variant: input.variant,
  186. })
  187. const wrote = queueSplash(
  188. renderer,
  189. state,
  190. "entry",
  191. entrySplash({
  192. ...meta,
  193. theme: theme.splash,
  194. showSession: splash.showSession,
  195. detail: directoryLabel(input.directory),
  196. }),
  197. )
  198. await renderer.idle().catch(() => {})
  199. const { RunFooter } = await footerTask
  200. let closed = false
  201. let sigintRegistered = false
  202. const footer = new RunFooter(renderer, {
  203. directory: input.directory,
  204. findFiles: input.findFiles,
  205. agents: input.agents,
  206. references: input.references,
  207. sessionID: input.getSessionID ?? (() => input.sessionID),
  208. ...labels,
  209. model: input.model,
  210. variant: input.variant,
  211. first: input.first,
  212. history: input.history,
  213. theme,
  214. wrote,
  215. keymap,
  216. tuiConfig,
  217. diffStyle: tuiConfig.diff_style ?? "auto",
  218. onPermissionReply: input.onPermissionReply,
  219. onQuestionReply: input.onQuestionReply,
  220. onQuestionReject: input.onQuestionReject,
  221. onCycleVariant: input.onCycleVariant,
  222. onModelSelect: input.onModelSelect,
  223. onVariantSelect: input.onVariantSelect,
  224. onInterrupt: input.onInterrupt,
  225. onBackground: input.onBackground,
  226. onEditorOpen: async ({ value }) => {
  227. if (closed || renderer.isDestroyed) {
  228. return
  229. }
  230. const { openEditor } = await import("@opencode-ai/tui/editor")
  231. await renderer.idle().catch(() => {})
  232. const ignore = () => {}
  233. detachSigint()
  234. process.on("SIGINT", ignore)
  235. try {
  236. return await openEditor({
  237. value,
  238. cwd: input.directory,
  239. renderer,
  240. stdin: source.stdin,
  241. })
  242. } finally {
  243. process.off("SIGINT", ignore)
  244. attachSigint()
  245. }
  246. },
  247. onSubagentSelect: input.onSubagentSelect,
  248. onSubagentInterrupt: input.onSubagentInterrupt,
  249. })
  250. const sigint = () => {
  251. footer.requestExit()
  252. }
  253. const attachSigint = () => {
  254. if (closed || sigintRegistered) {
  255. return
  256. }
  257. process.on("SIGINT", sigint)
  258. sigintRegistered = true
  259. }
  260. const detachSigint = () => {
  261. if (!sigintRegistered) {
  262. return
  263. }
  264. process.off("SIGINT", sigint)
  265. sigintRegistered = false
  266. }
  267. attachSigint()
  268. const close = async (next: {
  269. showExit: boolean
  270. sessionTitle?: string
  271. sessionID?: string
  272. history?: RunPrompt[]
  273. }) => {
  274. if (closed) {
  275. return
  276. }
  277. closed = true
  278. detachSigint()
  279. let wroteExit = false
  280. try {
  281. await footer.idle().catch(() => {})
  282. const show = renderer.isDestroyed ? false : next.showExit
  283. if (!renderer.isDestroyed && show) {
  284. const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
  285. const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
  286. wroteExit = queueSplash(
  287. renderer,
  288. state,
  289. "exit",
  290. exitSplash({
  291. ...splashMeta({
  292. title: splash.title,
  293. session_id: sessionID,
  294. }),
  295. theme: footer.currentTheme().splash,
  296. }),
  297. )
  298. await renderer.idle().catch(() => {})
  299. }
  300. } finally {
  301. footer.close()
  302. await footer.idle().catch(() => {})
  303. footer.destroy()
  304. unregisterKeymap?.()
  305. shutdown(renderer)
  306. if (!wroteExit) {
  307. process.stdout.write("\n")
  308. }
  309. source.cleanup?.()
  310. }
  311. }
  312. return {
  313. footer,
  314. refreshTheme() {
  315. footer.refreshTheme()
  316. },
  317. onResize(fn) {
  318. let width = renderer.terminalWidth
  319. let height = renderer.terminalHeight
  320. const resize = () => {
  321. if (width === renderer.terminalWidth && height === renderer.terminalHeight) {
  322. return
  323. }
  324. width = renderer.terminalWidth
  325. height = renderer.terminalHeight
  326. fn()
  327. }
  328. renderer.on(CliRenderEvents.RESIZE, resize)
  329. return () => renderer.off(CliRenderEvents.RESIZE, resize)
  330. },
  331. async resetForReplay(next) {
  332. if (closed || renderer.isDestroyed || footer.isClosed) {
  333. throw new Error("runtime closed")
  334. }
  335. await footer.idle()
  336. if (closed || renderer.isDestroyed || footer.isClosed) {
  337. throw new Error("runtime closed")
  338. }
  339. footer.resetForReplay(true)
  340. renderer.resetSplitFooterForReplay({ clearSavedLines: true })
  341. const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history)
  342. renderer.writeToScrollback(
  343. entrySplash({
  344. ...splashMeta({
  345. title: splash.title,
  346. session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
  347. }),
  348. theme: footer.currentTheme().splash,
  349. showSession: splash.showSession,
  350. detail: directoryLabel(input.directory),
  351. }),
  352. )
  353. renderer.requestRender()
  354. },
  355. close,
  356. }
  357. } catch (error) {
  358. unregisterKeymap?.()
  359. source.cleanup?.()
  360. throw error
  361. }
  362. }