Просмотр исходного кода

opencode/run: refresh themes after terminal reloads (#30917)

Simon Klee 3 месяцев назад
Родитель
Сommit
0c0d193474

+ 124 - 7
packages/opencode/src/cli/cmd/run/footer.ts

@@ -36,7 +36,7 @@ import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
 import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
 import { RunFooterView } from "./footer.view"
 import { RunScrollbackStream } from "./scrollback.surface"
-import type { RunTheme } from "./theme"
+import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
 import type {
   FooterApi,
   FooterEvent,
@@ -106,6 +106,7 @@ const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
 const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
 const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
 const AUTOCOMPLETE_COMPACT_ROWS = 2
+const THEME_REFRESH_DELAYS = [1000, 1000] as const
 
 function createEmptySubagentState(): FooterSubagentState {
   return {
@@ -191,6 +192,8 @@ export class RunFooter implements FooterApi {
   private setVariants: Setter<string[]>
   private currentVariant: Accessor<string | undefined>
   private setCurrentVariant: Setter<string | undefined>
+  private theme: Accessor<RunTheme>
+  private setTheme: Setter<RunTheme>
   private state: Accessor<FooterState>
   private setState: Setter<FooterState>
   private view: Accessor<FooterView>
@@ -206,13 +209,23 @@ export class RunFooter implements FooterApi {
   private exitTimeout: NodeJS.Timeout | undefined
   private requestExitHandler: (() => boolean) | undefined
   private scrollback: RunScrollbackStream
+  private themes: RunTheme[]
+  private paletteRefreshRunning = false
+  private paletteRefreshQueued = false
+  private themeRefreshTimeouts: NodeJS.Timeout[] = []
 
   private createScrollback(wrote: boolean): RunScrollbackStream {
-    return new RunScrollbackStream(this.renderer, this.options.theme, {
+    return new RunScrollbackStream(this.renderer, this.theme(), {
       diffStyle: this.options.diffStyle,
       wrote,
       sessionID: this.options.sessionID,
       treeSitterClient: this.options.treeSitterClient,
+      onThemeRelease: (theme) => {
+        void this.renderer
+          .idle()
+          .catch(() => { })
+          .finally(() => this.destroyTheme(theme))
+      },
     })
   }
 
@@ -257,6 +270,10 @@ export class RunFooter implements FooterApi {
     const [currentVariant, setCurrentVariant] = createSignal(options.variant)
     this.currentVariant = currentVariant
     this.setCurrentVariant = setCurrentVariant
+    const [theme, setTheme] = createSignal(options.theme)
+    this.theme = theme
+    this.setTheme = setTheme
+    this.themes = [options.theme]
     const [subagent, setSubagent] = createStore<FooterSubagentState>(createEmptySubagentState())
     this.subagent = () => subagent
     this.setSubagent = (next) => {
@@ -272,6 +289,10 @@ export class RunFooter implements FooterApi {
     this.scrollback = this.createScrollback(options.wrote ?? false)
 
     this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
+    this.renderer.on(CliRenderEvents.PALETTE, this.handlePalette)
+    this.renderer.on(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
+    this.renderer.prependInputHandler(this.handleThemeNotification)
+    process.on("SIGUSR2", this.handleThemeSignal)
 
     const footer = this
     void render(
@@ -293,7 +314,7 @@ export class RunFooter implements FooterApi {
               currentModel: footer.currentModel,
               variants: footer.variants,
               currentVariant: footer.currentVariant,
-              theme: options.theme,
+              theme: footer.theme,
               diffStyle: options.diffStyle,
               tuiConfig: options.tuiConfig,
               backgroundSubagents: options.backgroundSubagents,
@@ -353,7 +374,7 @@ export class RunFooter implements FooterApi {
   public onClose(fn: () => void): () => void {
     if (this.isClosed) {
       fn()
-      return () => {}
+      return () => { }
     }
 
     this.closes.add(fn)
@@ -548,7 +569,7 @@ export class RunFooter implements FooterApi {
         return this.idle()
       }
 
-      await this.renderer.idle().catch(() => {})
+      await this.renderer.idle().catch(() => { })
     })
   }
 
@@ -561,6 +582,21 @@ export class RunFooter implements FooterApi {
     this.scrollback = this.createScrollback(wrote)
   }
 
+  public currentTheme(): RunTheme {
+    return this.theme()
+  }
+
+  private destroyTheme(theme: RunTheme): void {
+    const index = this.themes.indexOf(theme)
+    if (index === -1) {
+      return
+    }
+
+    this.themes.splice(index, 1)
+    theme.block.syntax?.destroy()
+    theme.block.subtleSyntax?.destroy()
+  }
+
   public close(): void {
     if (this.closed) {
       return
@@ -783,7 +819,7 @@ export class RunFooter implements FooterApi {
           this.patch(patch)
         }
       })
-      .catch(() => {})
+      .catch(() => { })
   }
 
   private handleVariantSelect = (variant: string | undefined): void => {
@@ -825,7 +861,7 @@ export class RunFooter implements FooterApi {
           this.patch(patch)
         }
       })
-      .catch(() => {})
+      .catch(() => { })
   }
 
   private clearInterruptTimer(): void {
@@ -922,6 +958,80 @@ export class RunFooter implements FooterApi {
     return true
   }
 
+  private handlePalette = (): void => {
+    void resolveRunTheme(this.renderer).then((theme) => {
+      if (this.isGone) {
+        theme.block.syntax?.destroy()
+        theme.block.subtleSyntax?.destroy()
+        return
+      }
+
+      // Keep the last known good theme when a runtime OSC probe times out.
+      if (theme === RUN_THEME_FALLBACK) {
+        return
+      }
+
+      this.themes.push(theme)
+      this.setTheme(theme)
+      this.renderer.setBackgroundColor(theme.background)
+      this.flushing = this.flushing.then(() => this.scrollback.setTheme(theme)).catch((error) => {
+        this.flushError = error
+      })
+    })
+  }
+
+  private handleThemeNotification = (sequence: string): boolean => {
+    if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") {
+      return false
+    }
+
+    // OpenTUI clears its palette cache only when dark/light mode changes.
+    // Refresh for same-mode terminal theme swaps too.
+    queueMicrotask(this.handleThemeRefresh)
+    return false
+  }
+
+  private handleThemeRefresh = (): void => {
+    if (this.isGone) {
+      return
+    }
+
+    if (this.paletteRefreshRunning) {
+      this.paletteRefreshQueued = true
+      return
+    }
+
+    this.paletteRefreshRunning = true
+    const retry = this.renderer.paletteDetectionStatus === "detecting"
+    this.renderer.clearPaletteCache()
+    void this.renderer
+      .getPalette({ size: 256 })
+      .catch(() => { })
+      .finally(() => {
+        this.paletteRefreshRunning = false
+        if (!retry && !this.paletteRefreshQueued) {
+          return
+        }
+
+        this.paletteRefreshQueued = false
+        this.handleThemeRefresh()
+      })
+  }
+
+  public refreshTheme(): void {
+    this.handleThemeRefresh()
+  }
+
+  private handleThemeSignal = (): void => {
+    // Omarchy signals immediately after requesting a terminal config reload.
+    for (const timeout of this.themeRefreshTimeouts) clearTimeout(timeout)
+    this.themeRefreshTimeouts = THEME_REFRESH_DELAYS.map((delay) =>
+      setTimeout(() => {
+        this.handleThemeRefresh()
+      }, delay),
+    )
+  }
+
   private handleDestroy = (): void => {
     if (this.destroyed) {
       return
@@ -933,10 +1043,17 @@ export class RunFooter implements FooterApi {
     this.clearInterruptTimer()
     this.clearExitTimer()
     this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
+    this.renderer.off(CliRenderEvents.PALETTE, this.handlePalette)
+    this.renderer.off(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
+    this.renderer.removeInputHandler(this.handleThemeNotification)
+    process.off("SIGUSR2", this.handleThemeSignal)
+    for (const timeout of this.themeRefreshTimeouts) clearTimeout(timeout)
+    this.themeRefreshTimeouts.length = 0
     this.prompts.clear()
     this.queuedRemoves.clear()
     this.closes.clear()
     this.scrollback.destroy()
+    for (const theme of [...this.themes]) this.destroyTheme(theme)
   }
 
   // Drains the commit queue to scrollback. The surface manager owns grouping,

+ 3 - 3
packages/opencode/src/cli/cmd/run/footer.view.tsx

@@ -52,7 +52,7 @@ import type {
   RunResource,
   RunTuiConfig,
 } from "./types"
-import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
+import type { RunTheme } from "./theme"
 import { modelInfo } from "./variant.shared"
 
 const EMPTY_BORDER = {
@@ -83,7 +83,7 @@ type RunFooterViewProps = {
   view?: () => FooterView
   subagent?: () => FooterSubagentState
   queuedPrompts?: () => FooterQueuedPrompt[]
-  theme?: RunTheme
+  theme: () => RunTheme
   diffStyle?: RunDiffStyle
   tuiConfig: RunTuiConfig
   backgroundSubagents: boolean
@@ -237,7 +237,7 @@ export function RunFooterView(props: RunFooterViewProps) {
   const duration = createMemo(() => props.state().duration)
   const usage = createMemo(() => props.state().usage)
   const interruptKey = createMemo(() => interrupt() || "/exit")
-  const runTheme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
+  const runTheme = createMemo(() => props.theme())
   const theme = createMemo(() => runTheme().footer)
   const block = createMemo(() => runTheme().block)
   const spin = createMemo(() => {

+ 6 - 2
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts

@@ -78,6 +78,7 @@ export type LifecycleInput = {
 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>
 }
@@ -294,7 +295,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
                         title: splash.title,
                         session_id: sessionID,
                       }),
-                      theme: theme.splash,
+                      theme: footer.currentTheme().splash,
                     }),
                   )
                   await renderer.idle().catch(() => {})
@@ -313,6 +314,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
 
         return {
           footer,
+          refreshTheme() {
+            footer.refreshTheme()
+          },
           onResize(fn) {
             let width = renderer.terminalWidth
             let height = renderer.terminalHeight
@@ -347,7 +351,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
                   title: splash.title,
                   session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
                 }),
-                theme: theme.splash,
+                theme: footer.currentTheme().splash,
                 showSession: splash.showSession,
               }),
             )

+ 32 - 29
packages/opencode/src/cli/cmd/run/runtime.ts

@@ -143,7 +143,7 @@ function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
   return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
 }
 
-const REPLAY_RESIZE_DELAY = 250
+const RESIZE_DELAY = 250
 const LOCAL_REPLAY_ROW_LIMIT = 100
 
 async function resolveExitTitle(
@@ -526,35 +526,38 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
         return next
       }
 
-      let replayResizeTimer: ReturnType<typeof setTimeout> | undefined
-      const offResize = input.replay
-        ? shell.onResize(() => {
-            if (replayResizeTimer) {
-              clearTimeout(replayResizeTimer)
-            }
+      let resizeTimer: ReturnType<typeof setTimeout> | undefined
+      const offResize = shell.onResize(() => {
+        if (resizeTimer) {
+          clearTimeout(resizeTimer)
+        }
 
-            replayResizeTimer = setTimeout(() => {
-              replayResizeTimer = undefined
-              if (footer.isClosed || !state.stream) {
-                return
-              }
+        resizeTimer = setTimeout(() => {
+          resizeTimer = undefined
+          if (footer.isClosed) {
+            return
+          }
 
-              void state.stream
-                .then((item) =>
-                  item.handle.replayOnResize({
-                    localRows: () => state.localRows,
-                    reset: () =>
-                      shell.resetForReplay({
-                        sessionTitle: state.sessionTitle,
-                        sessionID: state.sessionID,
-                        history: state.history,
-                      }),
+          shell.refreshTheme()
+          if (!input.replay || !state.stream) {
+            return
+          }
+
+          void state.stream
+            .then((item) =>
+              item.handle.replayOnResize({
+                localRows: () => state.localRows,
+                reset: () =>
+                  shell.resetForReplay({
+                    sessionTitle: state.sessionTitle,
+                    sessionID: state.sessionID,
+                    history: state.history,
                   }),
-                )
-                .catch(() => {})
-            }, REPLAY_RESIZE_DELAY)
-          })
-        : () => {}
+              }),
+            )
+            .catch(() => {})
+        }, RESIZE_DELAY)
+      })
 
       const runQueue = async () => {
         let includeFiles = true
@@ -759,8 +762,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
         try {
           await runQueue()
         } finally {
-          if (replayResizeTimer) {
-            clearTimeout(replayResizeTimer)
+          if (resizeTimer) {
+            clearTimeout(resizeTimer)
           }
           offResize()
           await state.stream?.then((item) => item.handle.close()).catch(() => {})

+ 45 - 0
packages/opencode/src/cli/cmd/run/scrollback.surface.ts

@@ -92,6 +92,7 @@ export class RunScrollbackStream {
   private sessionID?: () => string | undefined
   private treeSitterClient: TreeSitterClient | undefined
   private wrote: boolean
+  private pendingThemes: RunTheme[] = []
 
   constructor(
     private renderer: CliRenderer,
@@ -101,12 +102,50 @@ export class RunScrollbackStream {
       diffStyle?: RunDiffStyle
       sessionID?: () => string | undefined
       treeSitterClient?: TreeSitterClient
+      onThemeRelease?: (theme: RunTheme) => void
     } = {},
   ) {
     this.diffStyle = options.diffStyle
     this.sessionID = options.sessionID
     this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
     this.wrote = options.wrote ?? false
+    this.onThemeRelease = options.onThemeRelease
+  }
+
+  private onThemeRelease: ((theme: RunTheme) => void) | undefined
+
+  private releasePendingThemes(): void {
+    if (this.pendingThemes.length === 0) {
+      return
+    }
+
+    for (const theme of this.pendingThemes.splice(0)) this.onThemeRelease?.(theme)
+  }
+
+  public setTheme(theme: RunTheme): void {
+    if (this.theme === theme) {
+      return
+    }
+
+    const previous = this.theme
+    this.theme = theme
+    const active = this.active
+    if (!active) {
+      this.onThemeRelease?.(previous)
+      return
+    }
+
+    this.pendingThemes.push(previous)
+
+    const style = entryLook(active.commit, theme.entry)
+    if (active.renderable instanceof TextRenderable) {
+      active.renderable.fg = style.fg
+      active.renderable.attributes = style.attrs ?? 0
+      return
+    }
+
+    active.renderable.fg = entryColor(active.commit, theme)
+    active.renderable.syntaxStyle = entrySyntax(active.commit, theme)
   }
 
   private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
@@ -203,6 +242,7 @@ export class RunScrollbackStream {
       const renderable = active.renderable
       renderable.content = active.content
       active.surface.render()
+      this.releasePendingThemes()
       const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
       if (targetRows <= active.committedRows) {
         return false
@@ -226,6 +266,7 @@ export class RunScrollbackStream {
       renderable.content = active.content
       renderable.streaming = !done
       await active.surface.settle()
+      this.releasePendingThemes()
       const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
       if (targetRows <= active.committedRows) {
         return false
@@ -248,6 +289,7 @@ export class RunScrollbackStream {
     renderable.content = active.content
     renderable.streaming = !done
     await active.surface.settle()
+    this.releasePendingThemes()
     const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount
     if (targetBlockCount <= active.committedBlocks) {
       return false
@@ -288,6 +330,7 @@ export class RunScrollbackStream {
       if (!active.surface.isDestroyed) {
         active.surface.destroy()
       }
+      this.releasePendingThemes()
     }
 
     return active.rendered ? active.commit : undefined
@@ -368,6 +411,7 @@ export class RunScrollbackStream {
     }
 
     this.active = undefined
+    this.releasePendingThemes()
   }
 
   public async complete(trailingNewline = false): Promise<void> {
@@ -386,5 +430,6 @@ export class RunScrollbackStream {
 
   public destroy(): void {
     this.resetActive()
+    this.releasePendingThemes()
   }
 }

+ 3 - 1
packages/opencode/src/cli/cmd/run/theme.ts

@@ -583,7 +583,9 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
       return RUN_THEME_FALLBACK
     }
 
-    const pick = renderer.themeMode ?? mode(RGBA.fromHex(bg))
+    // Palette-only terminal reloads can leave renderer.themeMode stale, but
+    // ANSI slot zero is not the terminal background when OSC 11 is absent.
+    const pick = colors.defaultBackground ? mode(RGBA.fromHex(colors.defaultBackground)) : renderer.themeMode ?? mode(RGBA.fromHex(bg))
     const theme = resolveTheme(generateSystem(colors, pick), pick)
     const indexed = indexedPalette(colors, 256)
     const shared = await import("../tui/context/theme")

+ 112 - 21
packages/opencode/src/cli/cmd/tui/context/theme.tsx

@@ -50,6 +50,7 @@ type Theme = TuiThemeCurrent & {
 }
 type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
 type SyntaxStyleOverrides = Record<string, { italic?: boolean }>
+const THEME_REFRESH_DELAYS = [250, 1000] as const
 
 export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
   // If theme explicitly defines selectedListItemText, use it
@@ -332,24 +333,29 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
       if (theme) setStore("active", theme)
     })
 
-    function init() {
+    function syncCustomThemes() {
+      return getCustomThemes()
+        .then((custom) => {
+          customThemes = custom
+          syncThemes()
+        })
+        .catch(() => {
+          setStore("active", "opencode")
+        })
+    }
+
+    onMount(() => {
       void Promise.allSettled([
         resolveSystemTheme(store.mode),
-        getCustomThemes()
-          .then((custom) => {
-            customThemes = custom
-            syncThemes()
-          })
-          .catch(() => {
-            setStore("active", "opencode")
-          }),
+        syncCustomThemes(),
       ]).finally(() => {
         setStore("ready", true)
       })
-    }
-
-    onMount(init)
+    })
 
+    let systemThemeSignature: string | undefined
+    let systemThemeMode: "dark" | "light" | undefined
+    let hasResolvedSystemTheme = false
     function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
       return renderer
         .getPalette({
@@ -357,6 +363,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
         })
         .then((colors: TerminalColors) => {
           if (!colors.palette[0]) {
+            // Keep the last known good generated theme during runtime reloads.
+            // A terminal config swap can briefly make OSC palette probes fail.
+            if (hasResolvedSystemTheme) return
             systemTheme = undefined
             syncThemes()
             if (store.active === "system") {
@@ -364,10 +373,20 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
             }
             return
           }
-          systemTheme = generateSystem(colors, mode)
+          const next = store.lock ?? terminalMode(colors) ?? mode
+          if (store.mode !== next) setStore("mode", next)
+          const signature = JSON.stringify(colors)
+          hasResolvedSystemTheme = true
+          // Delayed reload retries commonly observe the same palette. Avoid
+          // rebuilding native syntax styles unless the generated theme changed.
+          if (systemTheme && systemThemeSignature === signature && systemThemeMode === next) return
+          systemThemeSignature = signature
+          systemThemeMode = next
+          systemTheme = generateSystem(colors, next)
           syncThemes()
         })
         .catch(() => {
+          if (hasResolvedSystemTheme) return
           systemTheme = undefined
           syncThemes()
           if (store.active === "system") {
@@ -376,12 +395,33 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
         })
     }
 
+    let systemRefreshRunning = false
+    let systemRefreshQueued = false
+    let systemRefreshMode = store.mode
+    function refreshSystemTheme(mode: "dark" | "light" = store.mode) {
+      systemRefreshMode = mode
+      if (systemRefreshRunning) {
+        systemRefreshQueued = true
+        return
+      }
+
+      systemRefreshRunning = true
+      // clearPaletteCache() does not cancel an older in-flight detection.
+      const retry = renderer.paletteDetectionStatus === "detecting"
+      renderer.clearPaletteCache()
+      void resolveSystemTheme(mode).finally(() => {
+        systemRefreshRunning = false
+        if (!retry && !systemRefreshQueued) return
+        systemRefreshQueued = false
+        refreshSystemTheme(systemRefreshMode)
+      })
+    }
+
     function apply(mode: "dark" | "light") {
       if (store.lock !== undefined) kv.set("theme_mode", mode)
       if (store.mode === mode) return
       setStore("mode", mode)
-      renderer.clearPaletteCache()
-      void resolveSystemTheme(mode)
+      refreshSystemTheme(mode)
     }
 
     function pin(mode: "dark" | "light" = store.mode) {
@@ -394,8 +434,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
       setStore("lock", undefined)
       kv.set("theme_mode_lock", undefined)
       kv.set("theme_mode", undefined)
-      const mode = renderer.themeMode
-      if (mode) apply(mode)
+      refreshSystemTheme(renderer.themeMode ?? store.mode)
     }
 
     const handle = (mode: "dark" | "light") => {
@@ -404,15 +443,32 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
     }
     renderer.on(CliRenderEvents.THEME_MODE, handle)
 
+    const handleThemeNotification = (sequence: string) => {
+      if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") return false
+      queueMicrotask(() => refreshSystemTheme())
+      return false
+    }
+    renderer.prependInputHandler(handleThemeNotification)
+
+    let themeRefreshTimeouts: ReturnType<typeof setTimeout>[] = []
     const refresh = () => {
-      renderer.clearPaletteCache()
-      init()
+      // Omarchy signals immediately after requesting a terminal config reload.
+      for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
+      themeRefreshTimeouts = THEME_REFRESH_DELAYS.map((delay) =>
+        setTimeout(() => {
+          refreshSystemTheme()
+          if (delay === THEME_REFRESH_DELAYS[THEME_REFRESH_DELAYS.length - 1]) void syncCustomThemes()
+        }, delay),
+      )
     }
     process.on("SIGUSR2", refresh)
 
     onCleanup(() => {
       renderer.off(CliRenderEvents.THEME_MODE, handle)
+      renderer.removeInputHandler(handleThemeNotification)
       process.off("SIGUSR2", refresh)
+      for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
+      themeRefreshTimeouts.length = 0
     })
 
     const values = createMemo(() => {
@@ -436,8 +492,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
       renderer.setBackgroundColor(values().background)
     })
 
-    const syntax = createMemo(() => generateSyntax(values()))
-    const subtleSyntax = createMemo(() => generateSubtleSyntax(values()))
+    const syntax = createSyntaxStyleMemo(() => generateSyntax(values()))
+    const subtleSyntax = createSyntaxStyleMemo(() => generateSubtleSyntax(values()))
 
     return {
       theme: new Proxy(values(), {
@@ -519,6 +575,13 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
   return RGBA.fromInts(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255))
 }
 
+export function terminalMode(colors: TerminalColors): "dark" | "light" | undefined {
+  const bg = colors.defaultBackground
+  if (!bg) return
+  const { r, g, b } = RGBA.fromHex(bg)
+  return 0.299 * r + 0.587 * g + 0.114 * b > 0.5 ? "light" : "dark"
+}
+
 export function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
   const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
   const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
@@ -719,6 +782,34 @@ export function generateSyntax(theme: Theme) {
   return SyntaxStyle.fromTheme(getSyntaxRules(theme))
 }
 
+export function createSyntaxStyleMemo(factory: () => SyntaxStyle) {
+  const renderer = useRenderer()
+  const retained = new Set<SyntaxStyle>()
+  let current: SyntaxStyle | undefined
+
+  const release = (style: SyntaxStyle) => {
+    retained.add(style)
+    void renderer
+      .idle()
+      .catch(() => { })
+      .finally(() => {
+        if (!retained.delete(style)) return
+        style.destroy()
+      })
+  }
+
+  onCleanup(() => {
+    if (current) release(current)
+  })
+
+  return createMemo(() => {
+    const previous = current
+    current = factory()
+    if (previous) release(previous)
+    return current
+  })
+}
+
 export function generateSubtleSyntax(theme: Theme, overrides?: SyntaxStyleOverrides) {
   const rules = getSyntaxRules(theme)
   return SyntaxStyle.fromTheme(

+ 2 - 2
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -21,7 +21,7 @@ import { useSync } from "@tui/context/sync"
 import { useEvent } from "@tui/context/event"
 import { SplitBorder } from "@tui/component/border"
 import { Spinner } from "@tui/component/spinner"
-import { generateSubtleSyntax, selectedForeground, useTheme } from "@tui/context/theme"
+import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "@tui/context/theme"
 import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
 import { Prompt, type PromptRef } from "@tui/component/prompt"
 import type {
@@ -1600,7 +1600,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
     return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
   })
   const summary = createMemo(() => reasoningSummary(content()))
-  const syntax = createMemo(() => generateSubtleSyntax(theme))
+  const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme))
 
   const toggle = () => {
     if (!inMinimal()) return

+ 30 - 3
packages/opencode/test/cli/run/footer.view.test.tsx

@@ -1,5 +1,6 @@
 /** @jsxImportSource @opentui/solid */
 import { expect, test } from "bun:test"
+import { RGBA, type BoxRenderable } from "@opentui/core"
 import { testRender, useRenderer } from "@opentui/solid"
 import { createSignal } from "solid-js"
 import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
@@ -16,7 +17,7 @@ import {
 } from "@/cli/cmd/run/footer.command"
 import { RunFooterView } from "@/cli/cmd/run/footer.view"
 import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
-import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
+import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme"
 import type {
   FooterState,
   FooterSubagentState,
@@ -153,6 +154,7 @@ async function renderFooter(
   input: {
     tuiConfig?: RunTuiConfig
     commands?: RunCommand[]
+    theme?: () => RunTheme
     onCycle?: () => void
     onSubmit?: (prompt: RunPrompt) => boolean
   } = {},
@@ -183,7 +185,7 @@ async function renderFooter(
           state={state}
           view={view}
           subagent={subagents}
-          theme={RUN_THEME_FALLBACK}
+          theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
           tuiConfig={config}
           backgroundSubagents={true}
           agent="opencode"
@@ -227,6 +229,31 @@ async function renderFooter(
   }
 }
 
+test("direct footer updates composer background when theme changes", async () => {
+  const surface = RGBA.fromHex("#123456")
+  const [theme, setTheme] = createSignal(RUN_THEME_FALLBACK)
+  const app = await renderFooter({ theme })
+
+  try {
+    await app.renderOnce()
+    const area = app.renderer.root.findDescendantById("run-direct-footer-composer-area") as BoxRenderable
+
+    expect(area.backgroundColor.toInts()).not.toEqual(surface.toInts())
+    setTheme({
+      ...RUN_THEME_FALLBACK,
+      footer: {
+        ...RUN_THEME_FALLBACK.footer,
+        surface,
+      },
+    })
+    await app.renderOnce()
+
+    expect(area.backgroundColor.toInts()).toEqual(surface.toInts())
+  } finally {
+    app.cleanup()
+  }
+})
+
 test("run entry content updates when live commit text changes", async () => {
   const [commit, setCommit] = createSignal<StreamCommit>({
     kind: "tool",
@@ -612,7 +639,7 @@ test("direct footer shows editable prompts and additional queued work while runn
           queuedPrompts={() => [
             { messageID: "m-queued", partID: "p-queued", prompt: { text: "follow up", parts: [] } },
           ]}
-          theme={RUN_THEME_FALLBACK}
+          theme={() => RUN_THEME_FALLBACK}
           tuiConfig={tuiConfig}
           backgroundSubagents={true}
           agent="opencode"

+ 79 - 2
packages/opencode/test/cli/run/scrollback.surface.test.ts

@@ -1,8 +1,9 @@
 import { afterEach, expect, test } from "bun:test"
 import type { ToolPart } from "@opencode-ai/sdk/v2"
+import { RGBA, SyntaxStyle } from "@opentui/core"
 import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
 import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface"
-import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
+import { RUN_THEME_FALLBACK, type RunTheme } from "@/cli/cmd/run/theme"
 import type { StreamCommit } from "@/cli/cmd/run/types"
 
 type ClaimedCommit = {
@@ -62,6 +63,8 @@ async function setup(
   input: {
     width?: number
     wrote?: boolean
+    theme?: RunTheme
+    onThemeRelease?: (theme: RunTheme) => void
   } = {},
 ) {
   const out = await createTestRenderer({
@@ -78,9 +81,10 @@ async function setup(
 
   return {
     renderer: out.renderer,
-    scrollback: new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    scrollback: new RunScrollbackStream(out.renderer, input.theme ?? RUN_THEME_FALLBACK, {
       treeSitterClient,
       wrote: input.wrote ?? false,
+      onThemeRelease: input.onThemeRelease,
     }),
   }
 }
@@ -107,6 +111,79 @@ function reasoning(text: string, phase: StreamCommit["phase"] = "progress"): Str
   }
 }
 
+test("theme swaps restyle active reasoning without resetting the stream", async () => {
+  const previousSyntax = SyntaxStyle.fromStyles({ default: { fg: "#123456" } })
+  const nextSyntax = SyntaxStyle.fromStyles({ default: { fg: "#abcdef" } })
+  const released: RunTheme[] = []
+  const previous = {
+    ...RUN_THEME_FALLBACK,
+    block: {
+      ...RUN_THEME_FALLBACK.block,
+      subtleSyntax: previousSyntax,
+    },
+  }
+  const next = {
+    ...RUN_THEME_FALLBACK,
+    block: {
+      ...RUN_THEME_FALLBACK.block,
+      subtleSyntax: nextSyntax,
+    },
+  }
+  const out = await setup({ theme: previous, onThemeRelease: (theme) => released.push(theme) })
+
+  try {
+    await out.scrollback.append(reasoning("before"))
+    expect(activeSyntax(out.scrollback)).toBe(previousSyntax)
+
+    out.scrollback.setTheme(next)
+    expect(activeSyntax(out.scrollback)).toBe(nextSyntax)
+    expect(released).toEqual([])
+
+    await out.scrollback.append(reasoning("after"))
+    expect(activeSyntax(out.scrollback)).toBe(nextSyntax)
+    expect(released).toEqual([previous])
+  } finally {
+    out.scrollback.destroy()
+    destroy(claim(out.renderer))
+    previousSyntax.destroy()
+    nextSyntax.destroy()
+  }
+})
+
+function activeSyntax(scrollback: RunScrollbackStream) {
+  const entry = Reflect.get(scrollback, "active") as { renderable?: { syntaxStyle?: SyntaxStyle } } | undefined
+  return entry?.renderable?.syntaxStyle
+}
+
+test("theme swaps preserve streamed markdown parser state", async () => {
+  const out = await setup()
+  const next = {
+    ...RUN_THEME_FALLBACK,
+    footer: {
+      ...RUN_THEME_FALLBACK.footer,
+      surface: RGBA.fromHex("#123456"),
+    },
+  }
+
+  try {
+    await out.scrollback.append(assistant("```ts\nconst answer ="))
+    out.scrollback.setTheme(next)
+    await out.scrollback.append(assistant(" 42\n```"))
+    await out.scrollback.complete()
+
+    const commits = claim(out.renderer)
+    try {
+      const output = render(commits)
+      expect(output).toContain("const answer = 42")
+      expect(output).not.toContain("```")
+    } finally {
+      destroy(commits)
+    }
+  } finally {
+    out.scrollback.destroy()
+  }
+})
+
 function user(text: string): StreamCommit {
   return {
     kind: "user",

+ 37 - 0
packages/opencode/test/cli/run/theme.test.ts

@@ -82,6 +82,43 @@ test("returns syntax styles and indexed splash colors", async () => {
   }
 })
 
+test("uses refreshed background brightness when cached renderer mode is stale", async () => {
+  const colors = terminalColors({
+    defaultBackground: "#fbf1c7",
+    defaultForeground: "#3c3836",
+  })
+  const stale = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
+  const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
+
+  try {
+    expect(expectRgba(stale.footer.surface).toInts()).toEqual(expectRgba(light.footer.surface).toInts())
+  } finally {
+    stale.block.syntax?.destroy()
+    stale.block.subtleSyntax?.destroy()
+    light.block.syntax?.destroy()
+    light.block.subtleSyntax?.destroy()
+  }
+})
+
+test("keeps renderer mode when refreshed default background is unavailable", async () => {
+  const colors = {
+    ...terminalColors(),
+    defaultBackground: null,
+    palette: ["#000000", ...terminalColors().palette.slice(1)],
+  }
+  const light = await resolveRunTheme(renderer({ themeMode: "light", colors }))
+  const dark = await resolveRunTheme(renderer({ themeMode: "dark", colors }))
+
+  try {
+    expect(expectRgba(light.footer.surface).toInts()).not.toEqual(expectRgba(dark.footer.surface).toInts())
+  } finally {
+    light.block.syntax?.destroy()
+    light.block.subtleSyntax?.destroy()
+    dark.block.syntax?.destroy()
+    dark.block.subtleSyntax?.destroy()
+  }
+})
+
 test("keeps dark surfaces neutral on saturated backgrounds", () => {
   const theme = resolveTheme(
     generateSystem(

+ 26 - 1
packages/opencode/test/cli/tui/theme-store.test.ts

@@ -1,6 +1,7 @@
 import { expect, test } from "bun:test"
+import type { TerminalColors } from "@opentui/core"
 
-const { DEFAULT_THEMES, allThemes, addTheme, hasTheme, resolveTheme } = await import(
+const { DEFAULT_THEMES, allThemes, addTheme, hasTheme, resolveTheme, terminalMode } = await import(
   "../../../src/cli/cmd/tui/context/theme"
 )
 
@@ -49,3 +50,27 @@ test("resolveTheme rejects circular color refs", () => {
 
   expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
 })
+
+function terminalColors(defaultBackground: string | null, palette: Array<string | null> = []): TerminalColors {
+  return {
+    palette,
+    defaultForeground: null,
+    defaultBackground,
+    cursorColor: null,
+    mouseForeground: null,
+    mouseBackground: null,
+    tekForeground: null,
+    tekBackground: null,
+    highlightBackground: null,
+    highlightForeground: null,
+  }
+}
+
+test("terminalMode derives mode from refreshed background", () => {
+  expect(terminalMode(terminalColors("#fbf1c7"))).toBe("light")
+  expect(terminalMode(terminalColors("#1a1b26"))).toBe("dark")
+})
+
+test("terminalMode does not derive mode from ANSI slot zero", () => {
+  expect(terminalMode(terminalColors(null, ["#000000"]))).toBeUndefined()
+})