@@ -36,7 +36,7 @@ import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
3636import { PROMPT_MAX_ROWS , TEXTAREA_MIN_ROWS } from "./footer.prompt"
3737import { RunFooterView } from "./footer.view"
3838import { RunScrollbackStream } from "./scrollback.surface"
39- import type { RunTheme } from "./theme"
39+ import { RUN_THEME_FALLBACK , resolveRunTheme , type RunTheme } from "./theme"
4040import type {
4141 FooterApi ,
4242 FooterEvent ,
@@ -106,6 +106,7 @@ const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
106106const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
107107const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
108108const AUTOCOMPLETE_COMPACT_ROWS = 2
109+ const THEME_REFRESH_DELAYS = [ 1000 , 1000 ] as const
109110
110111function createEmptySubagentState ( ) : FooterSubagentState {
111112 return {
@@ -191,6 +192,8 @@ export class RunFooter implements FooterApi {
191192 private setVariants : Setter < string [ ] >
192193 private currentVariant : Accessor < string | undefined >
193194 private setCurrentVariant : Setter < string | undefined >
195+ private theme : Accessor < RunTheme >
196+ private setTheme : Setter < RunTheme >
194197 private state : Accessor < FooterState >
195198 private setState : Setter < FooterState >
196199 private view : Accessor < FooterView >
@@ -206,13 +209,23 @@ export class RunFooter implements FooterApi {
206209 private exitTimeout : NodeJS . Timeout | undefined
207210 private requestExitHandler : ( ( ) => boolean ) | undefined
208211 private scrollback : RunScrollbackStream
212+ private themes : RunTheme [ ]
213+ private paletteRefreshRunning = false
214+ private paletteRefreshQueued = false
215+ private themeRefreshTimeouts : NodeJS . Timeout [ ] = [ ]
209216
210217 private createScrollback ( wrote : boolean ) : RunScrollbackStream {
211- return new RunScrollbackStream ( this . renderer , this . options . theme , {
218+ return new RunScrollbackStream ( this . renderer , this . theme ( ) , {
212219 diffStyle : this . options . diffStyle ,
213220 wrote,
214221 sessionID : this . options . sessionID ,
215222 treeSitterClient : this . options . treeSitterClient ,
223+ onThemeRelease : ( theme ) => {
224+ void this . renderer
225+ . idle ( )
226+ . catch ( ( ) => { } )
227+ . finally ( ( ) => this . destroyTheme ( theme ) )
228+ } ,
216229 } )
217230 }
218231
@@ -257,6 +270,10 @@ export class RunFooter implements FooterApi {
257270 const [ currentVariant , setCurrentVariant ] = createSignal ( options . variant )
258271 this . currentVariant = currentVariant
259272 this . setCurrentVariant = setCurrentVariant
273+ const [ theme , setTheme ] = createSignal ( options . theme )
274+ this . theme = theme
275+ this . setTheme = setTheme
276+ this . themes = [ options . theme ]
260277 const [ subagent , setSubagent ] = createStore < FooterSubagentState > ( createEmptySubagentState ( ) )
261278 this . subagent = ( ) => subagent
262279 this . setSubagent = ( next ) => {
@@ -272,6 +289,10 @@ export class RunFooter implements FooterApi {
272289 this . scrollback = this . createScrollback ( options . wrote ?? false )
273290
274291 this . renderer . on ( CliRenderEvents . DESTROY , this . handleDestroy )
292+ this . renderer . on ( CliRenderEvents . PALETTE , this . handlePalette )
293+ this . renderer . on ( CliRenderEvents . THEME_MODE , this . handleThemeRefresh )
294+ this . renderer . prependInputHandler ( this . handleThemeNotification )
295+ process . on ( "SIGUSR2" , this . handleThemeSignal )
275296
276297 const footer = this
277298 void render (
@@ -293,7 +314,7 @@ export class RunFooter implements FooterApi {
293314 currentModel : footer . currentModel ,
294315 variants : footer . variants ,
295316 currentVariant : footer . currentVariant ,
296- theme : options . theme ,
317+ theme : footer . theme ,
297318 diffStyle : options . diffStyle ,
298319 tuiConfig : options . tuiConfig ,
299320 backgroundSubagents : options . backgroundSubagents ,
@@ -353,7 +374,7 @@ export class RunFooter implements FooterApi {
353374 public onClose ( fn : ( ) => void ) : ( ) => void {
354375 if ( this . isClosed ) {
355376 fn ( )
356- return ( ) => { }
377+ return ( ) => { }
357378 }
358379
359380 this . closes . add ( fn )
@@ -548,7 +569,7 @@ export class RunFooter implements FooterApi {
548569 return this . idle ( )
549570 }
550571
551- await this . renderer . idle ( ) . catch ( ( ) => { } )
572+ await this . renderer . idle ( ) . catch ( ( ) => { } )
552573 } )
553574 }
554575
@@ -561,6 +582,21 @@ export class RunFooter implements FooterApi {
561582 this . scrollback = this . createScrollback ( wrote )
562583 }
563584
585+ public currentTheme ( ) : RunTheme {
586+ return this . theme ( )
587+ }
588+
589+ private destroyTheme ( theme : RunTheme ) : void {
590+ const index = this . themes . indexOf ( theme )
591+ if ( index === - 1 ) {
592+ return
593+ }
594+
595+ this . themes . splice ( index , 1 )
596+ theme . block . syntax ?. destroy ( )
597+ theme . block . subtleSyntax ?. destroy ( )
598+ }
599+
564600 public close ( ) : void {
565601 if ( this . closed ) {
566602 return
@@ -783,7 +819,7 @@ export class RunFooter implements FooterApi {
783819 this . patch ( patch )
784820 }
785821 } )
786- . catch ( ( ) => { } )
822+ . catch ( ( ) => { } )
787823 }
788824
789825 private handleVariantSelect = ( variant : string | undefined ) : void => {
@@ -825,7 +861,7 @@ export class RunFooter implements FooterApi {
825861 this . patch ( patch )
826862 }
827863 } )
828- . catch ( ( ) => { } )
864+ . catch ( ( ) => { } )
829865 }
830866
831867 private clearInterruptTimer ( ) : void {
@@ -922,6 +958,80 @@ export class RunFooter implements FooterApi {
922958 return true
923959 }
924960
961+ private handlePalette = ( ) : void => {
962+ void resolveRunTheme ( this . renderer ) . then ( ( theme ) => {
963+ if ( this . isGone ) {
964+ theme . block . syntax ?. destroy ( )
965+ theme . block . subtleSyntax ?. destroy ( )
966+ return
967+ }
968+
969+ // Keep the last known good theme when a runtime OSC probe times out.
970+ if ( theme === RUN_THEME_FALLBACK ) {
971+ return
972+ }
973+
974+ this . themes . push ( theme )
975+ this . setTheme ( theme )
976+ this . renderer . setBackgroundColor ( theme . background )
977+ this . flushing = this . flushing . then ( ( ) => this . scrollback . setTheme ( theme ) ) . catch ( ( error ) => {
978+ this . flushError = error
979+ } )
980+ } )
981+ }
982+
983+ private handleThemeNotification = ( sequence : string ) : boolean => {
984+ if ( sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n" ) {
985+ return false
986+ }
987+
988+ // OpenTUI clears its palette cache only when dark/light mode changes.
989+ // Refresh for same-mode terminal theme swaps too.
990+ queueMicrotask ( this . handleThemeRefresh )
991+ return false
992+ }
993+
994+ private handleThemeRefresh = ( ) : void => {
995+ if ( this . isGone ) {
996+ return
997+ }
998+
999+ if ( this . paletteRefreshRunning ) {
1000+ this . paletteRefreshQueued = true
1001+ return
1002+ }
1003+
1004+ this . paletteRefreshRunning = true
1005+ const retry = this . renderer . paletteDetectionStatus === "detecting"
1006+ this . renderer . clearPaletteCache ( )
1007+ void this . renderer
1008+ . getPalette ( { size : 256 } )
1009+ . catch ( ( ) => { } )
1010+ . finally ( ( ) => {
1011+ this . paletteRefreshRunning = false
1012+ if ( ! retry && ! this . paletteRefreshQueued ) {
1013+ return
1014+ }
1015+
1016+ this . paletteRefreshQueued = false
1017+ this . handleThemeRefresh ( )
1018+ } )
1019+ }
1020+
1021+ public refreshTheme ( ) : void {
1022+ this . handleThemeRefresh ( )
1023+ }
1024+
1025+ private handleThemeSignal = ( ) : void => {
1026+ // Omarchy signals immediately after requesting a terminal config reload.
1027+ for ( const timeout of this . themeRefreshTimeouts ) clearTimeout ( timeout )
1028+ this . themeRefreshTimeouts = THEME_REFRESH_DELAYS . map ( ( delay ) =>
1029+ setTimeout ( ( ) => {
1030+ this . handleThemeRefresh ( )
1031+ } , delay ) ,
1032+ )
1033+ }
1034+
9251035 private handleDestroy = ( ) : void => {
9261036 if ( this . destroyed ) {
9271037 return
@@ -933,10 +1043,17 @@ export class RunFooter implements FooterApi {
9331043 this . clearInterruptTimer ( )
9341044 this . clearExitTimer ( )
9351045 this . renderer . off ( CliRenderEvents . DESTROY , this . handleDestroy )
1046+ this . renderer . off ( CliRenderEvents . PALETTE , this . handlePalette )
1047+ this . renderer . off ( CliRenderEvents . THEME_MODE , this . handleThemeRefresh )
1048+ this . renderer . removeInputHandler ( this . handleThemeNotification )
1049+ process . off ( "SIGUSR2" , this . handleThemeSignal )
1050+ for ( const timeout of this . themeRefreshTimeouts ) clearTimeout ( timeout )
1051+ this . themeRefreshTimeouts . length = 0
9361052 this . prompts . clear ( )
9371053 this . queuedRemoves . clear ( )
9381054 this . closes . clear ( )
9391055 this . scrollback . destroy ( )
1056+ for ( const theme of [ ...this . themes ] ) this . destroyTheme ( theme )
9401057 }
9411058
9421059 // Drains the commit queue to scrollback. The surface manager owns grouping,
0 commit comments