@@ -3,7 +3,7 @@ import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
33import * as Clipboard from "@tui/util/clipboard"
44import * as Selection from "@tui/util/selection"
55import * as TuiAudio from "@tui/util/audio"
6- import { createCliRenderer , MouseButton , type CliRendererConfig } from "@opentui/core"
6+ import { createCliRenderer , MouseButton , type CliRenderer , type CliRendererConfig } from "@opentui/core"
77import { RouteProvider , useRoute } from "@tui/context/route"
88import {
99 Switch ,
@@ -18,7 +18,7 @@ import {
1818 Show ,
1919 on ,
2020} from "solid-js"
21- import { win32DisableProcessedInput , win32InstallCtrlCGuard } from "./win32"
21+ import { win32DisableProcessedInput , win32FlushInputBuffer , win32InstallCtrlCGuard } from "./win32"
2222import { Flag } from "@opencode-ai/core/flag/flag"
2323import semver from "semver"
2424import { DialogProvider , useDialog } from "@tui/ui/dialog"
@@ -51,7 +51,7 @@ import { PromptStashProvider } from "./component/prompt/stash"
5151import { DialogAlert } from "./ui/dialog-alert"
5252import { DialogConfirm } from "./ui/dialog-confirm"
5353import { ToastProvider , useToast } from "./ui/toast"
54- import { ExitProvider , useExit } from "./context/exit"
54+ import { createExit , ExitProvider , useExit , type Exit } from "./context/exit"
5555import { Session as SessionApi } from "@/session/session"
5656import { TuiEvent } from "./event"
5757import { KVProvider , useKV } from "./context/kv"
@@ -123,7 +123,7 @@ const appBindingCommands = [
123123 "app.toggle.session_directory_filter" ,
124124] as const
125125
126- function rendererConfig ( _config : TuiConfig . Resolved ) : CliRendererConfig {
126+ export function tuiRendererConfig ( _config : TuiConfig . Resolved ) : CliRendererConfig {
127127 const mouseEnabled = ! Flag . OPENCODE_DISABLE_MOUSE && ( _config . mouse ?? true )
128128
129129 return {
@@ -146,6 +146,34 @@ function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
146146 }
147147}
148148
149+ export function createTuiRenderer ( config : TuiConfig . Resolved ) {
150+ return createCliRenderer ( tuiRendererConfig ( config ) )
151+ }
152+
153+ export type TuiHandle = {
154+ ready : Promise < void >
155+ done : Promise < void >
156+ exit : Exit
157+ }
158+
159+ type TuiInput = {
160+ url : string
161+ args : Args
162+ config : TuiConfig . Resolved
163+ renderer : CliRenderer
164+ onSnapshot ?: ( ) => Promise < string [ ] >
165+ directory ?: string
166+ fetch ?: typeof fetch
167+ headers ?: RequestInit [ "headers" ]
168+ events ?: EventSource
169+ }
170+
171+ type TuiLifecycle = {
172+ exit : Exit
173+ exited : Promise < void >
174+ fail ( error : unknown ) : Promise < never >
175+ }
176+
149177function errorMessage ( error : unknown ) {
150178 const formatted = FormatError ( error )
151179 if ( formatted !== undefined ) return formatted
@@ -163,105 +191,175 @@ function errorMessage(error: unknown) {
163191 return FormatUnknownError ( error )
164192}
165193
166- export function tui ( input : {
167- url : string
168- args : Args
169- config : TuiConfig . Resolved
170- onSnapshot ?: ( ) => Promise < string [ ] >
171- directory ?: string
172- fetch ?: typeof fetch
173- headers ?: RequestInit [ "headers" ]
174- events ?: EventSource
175- } ) {
176- // promise to prevent immediate exit
177- // oxlint-disable-next-line no-async-promise-executor -- intentional: async executor used for sequential setup before resolve
178- return new Promise < void > ( async ( resolve ) => {
179- const unguard = win32InstallCtrlCGuard ( )
180- win32DisableProcessedInput ( )
181-
182- const onExit = async ( ) => {
183- unguard ?.( )
184- resolve ( )
185- }
186- const onBeforeExit = async ( ) => {
187- offKeymap ( )
194+ export function tui ( input : TuiInput ) : TuiHandle {
195+ const unguard = win32InstallCtrlCGuard ( )
196+ win32DisableProcessedInput ( )
197+
198+ const renderer = input . renderer
199+ const keymap = createDefaultOpenTuiKeymap ( renderer )
200+ const unregisterKeymap = registerOpencodeKeymap ( keymap , renderer , input . config )
201+ const lifecycle = createTuiLifecycle ( {
202+ renderer,
203+ unguard,
204+ cleanup : async ( ) => {
205+ unregisterKeymap ( )
188206 await TuiPluginRuntime . dispose ( )
189207 TuiAudio . dispose ( )
208+ } ,
209+ } )
210+ const ready = mountTui ( { ...input , keymap, exit : lifecycle . exit } ) . catch ( ( error ) => lifecycle . fail ( error ) )
211+ const done = waitUntilDone ( ready , lifecycle . exited )
212+
213+ return { ready, done, exit : lifecycle . exit }
214+ }
215+
216+ async function mountTui ( input : TuiInput & { keymap : ReturnType < typeof createDefaultOpenTuiKeymap > ; exit : Exit } ) {
217+ const renderer = input . renderer
218+ // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
219+ void renderer . getPalette ( { size : 16 } ) . catch ( ( ) => undefined )
220+ const mode = ( await renderer . waitForThemeMode ( 1000 ) ) ?? "dark"
221+ if ( renderer . isDestroyed ) return
222+
223+ await render ( ( ) => {
224+ return (
225+ < ErrorBoundary
226+ fallback = { ( error , reset ) => < ErrorComponent error = { error } reset = { reset } exit = { input . exit } mode = { mode } /> }
227+ >
228+ < OpencodeKeymapProvider keymap = { input . keymap } >
229+ < ArgsProvider { ...input . args } >
230+ < ExitProvider exit = { input . exit } >
231+ < KVProvider >
232+ < ToastProvider >
233+ < RouteProvider
234+ initialRoute = {
235+ input . args . continue
236+ ? {
237+ type : "session" ,
238+ sessionID : "dummy" ,
239+ }
240+ : undefined
241+ }
242+ >
243+ < TuiConfigProvider config = { input . config } >
244+ < SDKProvider
245+ url = { input . url }
246+ directory = { input . directory }
247+ fetch = { input . fetch }
248+ headers = { input . headers }
249+ events = { input . events }
250+ >
251+ < ProjectProvider >
252+ < SyncProvider >
253+ < SyncProviderV2 >
254+ < ThemeProvider mode = { mode } >
255+ < LocalProvider >
256+ < PromptStashProvider >
257+ < DialogProvider >
258+ < FrecencyProvider >
259+ < PromptHistoryProvider >
260+ < PromptRefProvider >
261+ < EditorContextProvider >
262+ < App onSnapshot = { input . onSnapshot } />
263+ </ EditorContextProvider >
264+ </ PromptRefProvider >
265+ </ PromptHistoryProvider >
266+ </ FrecencyProvider >
267+ </ DialogProvider >
268+ </ PromptStashProvider >
269+ </ LocalProvider >
270+ </ ThemeProvider >
271+ </ SyncProviderV2 >
272+ </ SyncProvider >
273+ </ ProjectProvider >
274+ </ SDKProvider >
275+ </ TuiConfigProvider >
276+ </ RouteProvider >
277+ </ ToastProvider >
278+ </ KVProvider >
279+ </ ExitProvider >
280+ </ ArgsProvider >
281+ </ OpencodeKeymapProvider >
282+ </ ErrorBoundary >
283+ )
284+ } , renderer )
285+ }
286+
287+ function createTuiLifecycle ( input : {
288+ renderer : CliRenderer
289+ unguard ?: ( ) => void
290+ cleanup : ( ) => Promise < void >
291+ } ) : TuiLifecycle {
292+ let resolveExited ! : ( ) => void
293+ const exited = new Promise < void > ( ( resolve ) => {
294+ resolveExited = resolve
295+ } )
296+ let exitCompleted = false
297+ let exiting = false
298+ let cleanupTask : Promise < void > | undefined
299+
300+ const completeExit = ( ) => {
301+ if ( exitCompleted ) return
302+ exitCompleted = true
303+ resolveExited ( )
304+ }
305+
306+ const cleanup = ( ) => {
307+ cleanupTask ??= ( async ( ) => {
308+ process . off ( "SIGHUP" , onSighup )
309+ try {
310+ await input . cleanup ( )
311+ } finally {
312+ input . unguard ?.( )
313+ }
314+ } ) ( )
315+ return cleanupTask
316+ }
317+
318+ const exit = createExit ( async ( reason , message ) => {
319+ exiting = true
320+ await cleanup ( )
321+ if ( ! input . renderer . isDestroyed ) {
322+ input . renderer . setTerminalTitle ( "" )
323+ input . renderer . destroy ( )
190324 }
325+ win32FlushInputBuffer ( )
326+ if ( reason ) {
327+ const formatted = FormatError ( reason ) ?? FormatUnknownError ( reason )
328+ if ( formatted ) process . stderr . write ( formatted + "\n" )
329+ }
330+ const text = message ( )
331+ if ( text ) process . stdout . write ( text + "\n" )
332+ completeExit ( )
333+ } )
334+ const onSighup = ( ) => {
335+ void exit ( )
336+ }
191337
192- const renderer = await createCliRenderer ( rendererConfig ( input . config ) )
193- // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
194- void renderer . getPalette ( { size : 16 } ) . catch ( ( ) => undefined )
195- const mode = ( await renderer . waitForThemeMode ( 1000 ) ) ?? "dark"
196-
197- const keymap = createDefaultOpenTuiKeymap ( renderer )
198- const offKeymap = registerOpencodeKeymap ( keymap , renderer , input . config )
199-
200- await render ( ( ) => {
201- return (
202- < ErrorBoundary
203- fallback = { ( error , reset ) => (
204- < ErrorComponent error = { error } reset = { reset } onBeforeExit = { onBeforeExit } onExit = { onExit } mode = { mode } />
205- ) }
206- >
207- < OpencodeKeymapProvider keymap = { keymap } >
208- < ArgsProvider { ...input . args } >
209- < ExitProvider onBeforeExit = { onBeforeExit } onExit = { onExit } >
210- < KVProvider >
211- < ToastProvider >
212- < RouteProvider
213- initialRoute = {
214- input . args . continue
215- ? {
216- type : "session" ,
217- sessionID : "dummy" ,
218- }
219- : undefined
220- }
221- >
222- < TuiConfigProvider config = { input . config } >
223- < SDKProvider
224- url = { input . url }
225- directory = { input . directory }
226- fetch = { input . fetch }
227- headers = { input . headers }
228- events = { input . events }
229- >
230- < ProjectProvider >
231- < SyncProvider >
232- < SyncProviderV2 >
233- < ThemeProvider mode = { mode } >
234- < LocalProvider >
235- < PromptStashProvider >
236- < DialogProvider >
237- < FrecencyProvider >
238- < PromptHistoryProvider >
239- < PromptRefProvider >
240- < EditorContextProvider >
241- < App onSnapshot = { input . onSnapshot } />
242- </ EditorContextProvider >
243- </ PromptRefProvider >
244- </ PromptHistoryProvider >
245- </ FrecencyProvider >
246- </ DialogProvider >
247- </ PromptStashProvider >
248- </ LocalProvider >
249- </ ThemeProvider >
250- </ SyncProviderV2 >
251- </ SyncProvider >
252- </ ProjectProvider >
253- </ SDKProvider >
254- </ TuiConfigProvider >
255- </ RouteProvider >
256- </ ToastProvider >
257- </ KVProvider >
258- </ ExitProvider >
259- </ ArgsProvider >
260- </ OpencodeKeymapProvider >
261- </ ErrorBoundary >
262- )
263- } , renderer )
338+ input . renderer . once ( "destroy" , ( ) => {
339+ if ( exiting ) return
340+ void cleanup ( ) . finally ( ( ) => {
341+ win32FlushInputBuffer ( )
342+ completeExit ( )
343+ } )
264344 } )
345+ process . on ( "SIGHUP" , onSighup )
346+
347+ return {
348+ exit,
349+ exited,
350+ async fail ( error ) {
351+ exiting = true
352+ await cleanup ( ) . catch ( ( ) => { } )
353+ if ( ! input . renderer . isDestroyed ) input . renderer . destroy ( )
354+ completeExit ( )
355+ throw error
356+ } ,
357+ }
358+ }
359+
360+ async function waitUntilDone ( ready : Promise < void > , exited : Promise < void > ) {
361+ await ready
362+ await exited
265363}
266364
267365function App ( props : { onSnapshot ?: ( ) => Promise < string [ ] > } ) {
0 commit comments