opencode/run: refresh themes after terminal reloads (#30917)
This commit is contained in:
parent
b278e49e96
commit
0c0d193474
12 changed files with 499 additions and 71 deletions
|
|
@ -36,7 +36,7 @@ import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
||||||
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
||||||
import { RunFooterView } from "./footer.view"
|
import { RunFooterView } from "./footer.view"
|
||||||
import { RunScrollbackStream } from "./scrollback.surface"
|
import { RunScrollbackStream } from "./scrollback.surface"
|
||||||
import type { RunTheme } from "./theme"
|
import { RUN_THEME_FALLBACK, resolveRunTheme, type RunTheme } from "./theme"
|
||||||
import type {
|
import type {
|
||||||
FooterApi,
|
FooterApi,
|
||||||
FooterEvent,
|
FooterEvent,
|
||||||
|
|
@ -106,6 +106,7 @@ const SUBAGENT_ROWS = RUN_SUBAGENT_PANEL_ROWS
|
||||||
const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
|
const MODEL_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||||
const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
|
const VARIANT_ROWS = RUN_COMMAND_PANEL_ROWS
|
||||||
const AUTOCOMPLETE_COMPACT_ROWS = 2
|
const AUTOCOMPLETE_COMPACT_ROWS = 2
|
||||||
|
const THEME_REFRESH_DELAYS = [1000, 1000] as const
|
||||||
|
|
||||||
function createEmptySubagentState(): FooterSubagentState {
|
function createEmptySubagentState(): FooterSubagentState {
|
||||||
return {
|
return {
|
||||||
|
|
@ -191,6 +192,8 @@ export class RunFooter implements FooterApi {
|
||||||
private setVariants: Setter<string[]>
|
private setVariants: Setter<string[]>
|
||||||
private currentVariant: Accessor<string | undefined>
|
private currentVariant: Accessor<string | undefined>
|
||||||
private setCurrentVariant: Setter<string | undefined>
|
private setCurrentVariant: Setter<string | undefined>
|
||||||
|
private theme: Accessor<RunTheme>
|
||||||
|
private setTheme: Setter<RunTheme>
|
||||||
private state: Accessor<FooterState>
|
private state: Accessor<FooterState>
|
||||||
private setState: Setter<FooterState>
|
private setState: Setter<FooterState>
|
||||||
private view: Accessor<FooterView>
|
private view: Accessor<FooterView>
|
||||||
|
|
@ -206,13 +209,23 @@ export class RunFooter implements FooterApi {
|
||||||
private exitTimeout: NodeJS.Timeout | undefined
|
private exitTimeout: NodeJS.Timeout | undefined
|
||||||
private requestExitHandler: (() => boolean) | undefined
|
private requestExitHandler: (() => boolean) | undefined
|
||||||
private scrollback: RunScrollbackStream
|
private scrollback: RunScrollbackStream
|
||||||
|
private themes: RunTheme[]
|
||||||
|
private paletteRefreshRunning = false
|
||||||
|
private paletteRefreshQueued = false
|
||||||
|
private themeRefreshTimeouts: NodeJS.Timeout[] = []
|
||||||
|
|
||||||
private createScrollback(wrote: boolean): RunScrollbackStream {
|
private createScrollback(wrote: boolean): RunScrollbackStream {
|
||||||
return new RunScrollbackStream(this.renderer, this.options.theme, {
|
return new RunScrollbackStream(this.renderer, this.theme(), {
|
||||||
diffStyle: this.options.diffStyle,
|
diffStyle: this.options.diffStyle,
|
||||||
wrote,
|
wrote,
|
||||||
sessionID: this.options.sessionID,
|
sessionID: this.options.sessionID,
|
||||||
treeSitterClient: this.options.treeSitterClient,
|
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)
|
const [currentVariant, setCurrentVariant] = createSignal(options.variant)
|
||||||
this.currentVariant = currentVariant
|
this.currentVariant = currentVariant
|
||||||
this.setCurrentVariant = setCurrentVariant
|
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())
|
const [subagent, setSubagent] = createStore<FooterSubagentState>(createEmptySubagentState())
|
||||||
this.subagent = () => subagent
|
this.subagent = () => subagent
|
||||||
this.setSubagent = (next) => {
|
this.setSubagent = (next) => {
|
||||||
|
|
@ -272,6 +289,10 @@ export class RunFooter implements FooterApi {
|
||||||
this.scrollback = this.createScrollback(options.wrote ?? false)
|
this.scrollback = this.createScrollback(options.wrote ?? false)
|
||||||
|
|
||||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
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
|
const footer = this
|
||||||
void render(
|
void render(
|
||||||
|
|
@ -293,7 +314,7 @@ export class RunFooter implements FooterApi {
|
||||||
currentModel: footer.currentModel,
|
currentModel: footer.currentModel,
|
||||||
variants: footer.variants,
|
variants: footer.variants,
|
||||||
currentVariant: footer.currentVariant,
|
currentVariant: footer.currentVariant,
|
||||||
theme: options.theme,
|
theme: footer.theme,
|
||||||
diffStyle: options.diffStyle,
|
diffStyle: options.diffStyle,
|
||||||
tuiConfig: options.tuiConfig,
|
tuiConfig: options.tuiConfig,
|
||||||
backgroundSubagents: options.backgroundSubagents,
|
backgroundSubagents: options.backgroundSubagents,
|
||||||
|
|
@ -353,7 +374,7 @@ export class RunFooter implements FooterApi {
|
||||||
public onClose(fn: () => void): () => void {
|
public onClose(fn: () => void): () => void {
|
||||||
if (this.isClosed) {
|
if (this.isClosed) {
|
||||||
fn()
|
fn()
|
||||||
return () => {}
|
return () => { }
|
||||||
}
|
}
|
||||||
|
|
||||||
this.closes.add(fn)
|
this.closes.add(fn)
|
||||||
|
|
@ -548,7 +569,7 @@ export class RunFooter implements FooterApi {
|
||||||
return this.idle()
|
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)
|
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 {
|
public close(): void {
|
||||||
if (this.closed) {
|
if (this.closed) {
|
||||||
return
|
return
|
||||||
|
|
@ -783,7 +819,7 @@ export class RunFooter implements FooterApi {
|
||||||
this.patch(patch)
|
this.patch(patch)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleVariantSelect = (variant: string | undefined): void => {
|
private handleVariantSelect = (variant: string | undefined): void => {
|
||||||
|
|
@ -825,7 +861,7 @@ export class RunFooter implements FooterApi {
|
||||||
this.patch(patch)
|
this.patch(patch)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearInterruptTimer(): void {
|
private clearInterruptTimer(): void {
|
||||||
|
|
@ -922,6 +958,80 @@ export class RunFooter implements FooterApi {
|
||||||
return true
|
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 => {
|
private handleDestroy = (): void => {
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
return
|
return
|
||||||
|
|
@ -933,10 +1043,17 @@ export class RunFooter implements FooterApi {
|
||||||
this.clearInterruptTimer()
|
this.clearInterruptTimer()
|
||||||
this.clearExitTimer()
|
this.clearExitTimer()
|
||||||
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
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.prompts.clear()
|
||||||
this.queuedRemoves.clear()
|
this.queuedRemoves.clear()
|
||||||
this.closes.clear()
|
this.closes.clear()
|
||||||
this.scrollback.destroy()
|
this.scrollback.destroy()
|
||||||
|
for (const theme of [...this.themes]) this.destroyTheme(theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drains the commit queue to scrollback. The surface manager owns grouping,
|
// Drains the commit queue to scrollback. The surface manager owns grouping,
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ import type {
|
||||||
RunResource,
|
RunResource,
|
||||||
RunTuiConfig,
|
RunTuiConfig,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
|
import type { RunTheme } from "./theme"
|
||||||
import { modelInfo } from "./variant.shared"
|
import { modelInfo } from "./variant.shared"
|
||||||
|
|
||||||
const EMPTY_BORDER = {
|
const EMPTY_BORDER = {
|
||||||
|
|
@ -83,7 +83,7 @@ type RunFooterViewProps = {
|
||||||
view?: () => FooterView
|
view?: () => FooterView
|
||||||
subagent?: () => FooterSubagentState
|
subagent?: () => FooterSubagentState
|
||||||
queuedPrompts?: () => FooterQueuedPrompt[]
|
queuedPrompts?: () => FooterQueuedPrompt[]
|
||||||
theme?: RunTheme
|
theme: () => RunTheme
|
||||||
diffStyle?: RunDiffStyle
|
diffStyle?: RunDiffStyle
|
||||||
tuiConfig: RunTuiConfig
|
tuiConfig: RunTuiConfig
|
||||||
backgroundSubagents: boolean
|
backgroundSubagents: boolean
|
||||||
|
|
@ -237,7 +237,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||||
const duration = createMemo(() => props.state().duration)
|
const duration = createMemo(() => props.state().duration)
|
||||||
const usage = createMemo(() => props.state().usage)
|
const usage = createMemo(() => props.state().usage)
|
||||||
const interruptKey = createMemo(() => interrupt() || "/exit")
|
const interruptKey = createMemo(() => interrupt() || "/exit")
|
||||||
const runTheme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
const runTheme = createMemo(() => props.theme())
|
||||||
const theme = createMemo(() => runTheme().footer)
|
const theme = createMemo(() => runTheme().footer)
|
||||||
const block = createMemo(() => runTheme().block)
|
const block = createMemo(() => runTheme().block)
|
||||||
const spin = createMemo(() => {
|
const spin = createMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ export type LifecycleInput = {
|
||||||
export type Lifecycle = {
|
export type Lifecycle = {
|
||||||
footer: FooterApi
|
footer: FooterApi
|
||||||
onResize(fn: () => void): () => void
|
onResize(fn: () => void): () => void
|
||||||
|
refreshTheme(): void
|
||||||
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
|
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
|
||||||
close(input: { showExit: boolean; 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,
|
title: splash.title,
|
||||||
session_id: sessionID,
|
session_id: sessionID,
|
||||||
}),
|
}),
|
||||||
theme: theme.splash,
|
theme: footer.currentTheme().splash,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await renderer.idle().catch(() => {})
|
await renderer.idle().catch(() => {})
|
||||||
|
|
@ -313,6 +314,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||||
|
|
||||||
return {
|
return {
|
||||||
footer,
|
footer,
|
||||||
|
refreshTheme() {
|
||||||
|
footer.refreshTheme()
|
||||||
|
},
|
||||||
onResize(fn) {
|
onResize(fn) {
|
||||||
let width = renderer.terminalWidth
|
let width = renderer.terminalWidth
|
||||||
let height = renderer.terminalHeight
|
let height = renderer.terminalHeight
|
||||||
|
|
@ -347,7 +351,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||||
title: splash.title,
|
title: splash.title,
|
||||||
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
|
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
|
||||||
}),
|
}),
|
||||||
theme: theme.splash,
|
theme: footer.currentTheme().splash,
|
||||||
showSession: splash.showSession,
|
showSession: splash.showSession,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 ?? {})
|
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
|
const LOCAL_REPLAY_ROW_LIMIT = 100
|
||||||
|
|
||||||
async function resolveExitTitle(
|
async function resolveExitTitle(
|
||||||
|
|
@ -526,35 +526,38 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
let replayResizeTimer: ReturnType<typeof setTimeout> | undefined
|
let resizeTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
const offResize = input.replay
|
const offResize = shell.onResize(() => {
|
||||||
? shell.onResize(() => {
|
if (resizeTimer) {
|
||||||
if (replayResizeTimer) {
|
clearTimeout(resizeTimer)
|
||||||
clearTimeout(replayResizeTimer)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
replayResizeTimer = setTimeout(() => {
|
resizeTimer = setTimeout(() => {
|
||||||
replayResizeTimer = undefined
|
resizeTimer = undefined
|
||||||
if (footer.isClosed || !state.stream) {
|
if (footer.isClosed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
void state.stream
|
shell.refreshTheme()
|
||||||
.then((item) =>
|
if (!input.replay || !state.stream) {
|
||||||
item.handle.replayOnResize({
|
return
|
||||||
localRows: () => state.localRows,
|
}
|
||||||
reset: () =>
|
|
||||||
shell.resetForReplay({
|
void state.stream
|
||||||
sessionTitle: state.sessionTitle,
|
.then((item) =>
|
||||||
sessionID: state.sessionID,
|
item.handle.replayOnResize({
|
||||||
history: state.history,
|
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 () => {
|
const runQueue = async () => {
|
||||||
let includeFiles = true
|
let includeFiles = true
|
||||||
|
|
@ -759,8 +762,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await runQueue()
|
await runQueue()
|
||||||
} finally {
|
} finally {
|
||||||
if (replayResizeTimer) {
|
if (resizeTimer) {
|
||||||
clearTimeout(replayResizeTimer)
|
clearTimeout(resizeTimer)
|
||||||
}
|
}
|
||||||
offResize()
|
offResize()
|
||||||
await state.stream?.then((item) => item.handle.close()).catch(() => {})
|
await state.stream?.then((item) => item.handle.close()).catch(() => {})
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ export class RunScrollbackStream {
|
||||||
private sessionID?: () => string | undefined
|
private sessionID?: () => string | undefined
|
||||||
private treeSitterClient: TreeSitterClient | undefined
|
private treeSitterClient: TreeSitterClient | undefined
|
||||||
private wrote: boolean
|
private wrote: boolean
|
||||||
|
private pendingThemes: RunTheme[] = []
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private renderer: CliRenderer,
|
private renderer: CliRenderer,
|
||||||
|
|
@ -101,12 +102,50 @@ export class RunScrollbackStream {
|
||||||
diffStyle?: RunDiffStyle
|
diffStyle?: RunDiffStyle
|
||||||
sessionID?: () => string | undefined
|
sessionID?: () => string | undefined
|
||||||
treeSitterClient?: TreeSitterClient
|
treeSitterClient?: TreeSitterClient
|
||||||
|
onThemeRelease?: (theme: RunTheme) => void
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
this.diffStyle = options.diffStyle
|
this.diffStyle = options.diffStyle
|
||||||
this.sessionID = options.sessionID
|
this.sessionID = options.sessionID
|
||||||
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
|
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
|
||||||
this.wrote = options.wrote ?? false
|
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 {
|
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
|
||||||
|
|
@ -203,6 +242,7 @@ export class RunScrollbackStream {
|
||||||
const renderable = active.renderable
|
const renderable = active.renderable
|
||||||
renderable.content = active.content
|
renderable.content = active.content
|
||||||
active.surface.render()
|
active.surface.render()
|
||||||
|
this.releasePendingThemes()
|
||||||
const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
|
const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
|
||||||
if (targetRows <= active.committedRows) {
|
if (targetRows <= active.committedRows) {
|
||||||
return false
|
return false
|
||||||
|
|
@ -226,6 +266,7 @@ export class RunScrollbackStream {
|
||||||
renderable.content = active.content
|
renderable.content = active.content
|
||||||
renderable.streaming = !done
|
renderable.streaming = !done
|
||||||
await active.surface.settle()
|
await active.surface.settle()
|
||||||
|
this.releasePendingThemes()
|
||||||
const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
|
const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
|
||||||
if (targetRows <= active.committedRows) {
|
if (targetRows <= active.committedRows) {
|
||||||
return false
|
return false
|
||||||
|
|
@ -248,6 +289,7 @@ export class RunScrollbackStream {
|
||||||
renderable.content = active.content
|
renderable.content = active.content
|
||||||
renderable.streaming = !done
|
renderable.streaming = !done
|
||||||
await active.surface.settle()
|
await active.surface.settle()
|
||||||
|
this.releasePendingThemes()
|
||||||
const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount
|
const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount
|
||||||
if (targetBlockCount <= active.committedBlocks) {
|
if (targetBlockCount <= active.committedBlocks) {
|
||||||
return false
|
return false
|
||||||
|
|
@ -288,6 +330,7 @@ export class RunScrollbackStream {
|
||||||
if (!active.surface.isDestroyed) {
|
if (!active.surface.isDestroyed) {
|
||||||
active.surface.destroy()
|
active.surface.destroy()
|
||||||
}
|
}
|
||||||
|
this.releasePendingThemes()
|
||||||
}
|
}
|
||||||
|
|
||||||
return active.rendered ? active.commit : undefined
|
return active.rendered ? active.commit : undefined
|
||||||
|
|
@ -368,6 +411,7 @@ export class RunScrollbackStream {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.active = undefined
|
this.active = undefined
|
||||||
|
this.releasePendingThemes()
|
||||||
}
|
}
|
||||||
|
|
||||||
public async complete(trailingNewline = false): Promise<void> {
|
public async complete(trailingNewline = false): Promise<void> {
|
||||||
|
|
@ -386,5 +430,6 @@ export class RunScrollbackStream {
|
||||||
|
|
||||||
public destroy(): void {
|
public destroy(): void {
|
||||||
this.resetActive()
|
this.resetActive()
|
||||||
|
this.releasePendingThemes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -583,7 +583,9 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
|
||||||
return RUN_THEME_FALLBACK
|
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 theme = resolveTheme(generateSystem(colors, pick), pick)
|
||||||
const indexed = indexedPalette(colors, 256)
|
const indexed = indexedPalette(colors, 256)
|
||||||
const shared = await import("../tui/context/theme")
|
const shared = await import("../tui/context/theme")
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ type Theme = TuiThemeCurrent & {
|
||||||
}
|
}
|
||||||
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
|
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
|
||||||
type SyntaxStyleOverrides = Record<string, { italic?: boolean }>
|
type SyntaxStyleOverrides = Record<string, { italic?: boolean }>
|
||||||
|
const THEME_REFRESH_DELAYS = [250, 1000] as const
|
||||||
|
|
||||||
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
|
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
|
||||||
// If theme explicitly defines selectedListItemText, use it
|
// If theme explicitly defines selectedListItemText, use it
|
||||||
|
|
@ -332,24 +333,29 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||||
if (theme) setStore("active", theme)
|
if (theme) setStore("active", theme)
|
||||||
})
|
})
|
||||||
|
|
||||||
function init() {
|
function syncCustomThemes() {
|
||||||
|
return getCustomThemes()
|
||||||
|
.then((custom) => {
|
||||||
|
customThemes = custom
|
||||||
|
syncThemes()
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setStore("active", "opencode")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
void Promise.allSettled([
|
void Promise.allSettled([
|
||||||
resolveSystemTheme(store.mode),
|
resolveSystemTheme(store.mode),
|
||||||
getCustomThemes()
|
syncCustomThemes(),
|
||||||
.then((custom) => {
|
|
||||||
customThemes = custom
|
|
||||||
syncThemes()
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setStore("active", "opencode")
|
|
||||||
}),
|
|
||||||
]).finally(() => {
|
]).finally(() => {
|
||||||
setStore("ready", true)
|
setStore("ready", true)
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
|
|
||||||
onMount(init)
|
|
||||||
|
|
||||||
|
let systemThemeSignature: string | undefined
|
||||||
|
let systemThemeMode: "dark" | "light" | undefined
|
||||||
|
let hasResolvedSystemTheme = false
|
||||||
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
||||||
return renderer
|
return renderer
|
||||||
.getPalette({
|
.getPalette({
|
||||||
|
|
@ -357,6 +363,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||||
})
|
})
|
||||||
.then((colors: TerminalColors) => {
|
.then((colors: TerminalColors) => {
|
||||||
if (!colors.palette[0]) {
|
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
|
systemTheme = undefined
|
||||||
syncThemes()
|
syncThemes()
|
||||||
if (store.active === "system") {
|
if (store.active === "system") {
|
||||||
|
|
@ -364,10 +373,20 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||||
}
|
}
|
||||||
return
|
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()
|
syncThemes()
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
if (hasResolvedSystemTheme) return
|
||||||
systemTheme = undefined
|
systemTheme = undefined
|
||||||
syncThemes()
|
syncThemes()
|
||||||
if (store.active === "system") {
|
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") {
|
function apply(mode: "dark" | "light") {
|
||||||
if (store.lock !== undefined) kv.set("theme_mode", mode)
|
if (store.lock !== undefined) kv.set("theme_mode", mode)
|
||||||
if (store.mode === mode) return
|
if (store.mode === mode) return
|
||||||
setStore("mode", mode)
|
setStore("mode", mode)
|
||||||
renderer.clearPaletteCache()
|
refreshSystemTheme(mode)
|
||||||
void resolveSystemTheme(mode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pin(mode: "dark" | "light" = store.mode) {
|
function pin(mode: "dark" | "light" = store.mode) {
|
||||||
|
|
@ -394,8 +434,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||||
setStore("lock", undefined)
|
setStore("lock", undefined)
|
||||||
kv.set("theme_mode_lock", undefined)
|
kv.set("theme_mode_lock", undefined)
|
||||||
kv.set("theme_mode", undefined)
|
kv.set("theme_mode", undefined)
|
||||||
const mode = renderer.themeMode
|
refreshSystemTheme(renderer.themeMode ?? store.mode)
|
||||||
if (mode) apply(mode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handle = (mode: "dark" | "light") => {
|
const handle = (mode: "dark" | "light") => {
|
||||||
|
|
@ -404,15 +443,32 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||||
}
|
}
|
||||||
renderer.on(CliRenderEvents.THEME_MODE, handle)
|
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 = () => {
|
const refresh = () => {
|
||||||
renderer.clearPaletteCache()
|
// Omarchy signals immediately after requesting a terminal config reload.
|
||||||
init()
|
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)
|
process.on("SIGUSR2", refresh)
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
renderer.off(CliRenderEvents.THEME_MODE, handle)
|
renderer.off(CliRenderEvents.THEME_MODE, handle)
|
||||||
|
renderer.removeInputHandler(handleThemeNotification)
|
||||||
process.off("SIGUSR2", refresh)
|
process.off("SIGUSR2", refresh)
|
||||||
|
for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
|
||||||
|
themeRefreshTimeouts.length = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const values = createMemo(() => {
|
const values = createMemo(() => {
|
||||||
|
|
@ -436,8 +492,8 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||||
renderer.setBackgroundColor(values().background)
|
renderer.setBackgroundColor(values().background)
|
||||||
})
|
})
|
||||||
|
|
||||||
const syntax = createMemo(() => generateSyntax(values()))
|
const syntax = createSyntaxStyleMemo(() => generateSyntax(values()))
|
||||||
const subtleSyntax = createMemo(() => generateSubtleSyntax(values()))
|
const subtleSyntax = createSyntaxStyleMemo(() => generateSubtleSyntax(values()))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme: new Proxy(values(), {
|
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))
|
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 {
|
export function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
|
||||||
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||||
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||||
|
|
@ -719,6 +782,34 @@ export function generateSyntax(theme: Theme) {
|
||||||
return SyntaxStyle.fromTheme(getSyntaxRules(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) {
|
export function generateSubtleSyntax(theme: Theme, overrides?: SyntaxStyleOverrides) {
|
||||||
const rules = getSyntaxRules(theme)
|
const rules = getSyntaxRules(theme)
|
||||||
return SyntaxStyle.fromTheme(
|
return SyntaxStyle.fromTheme(
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { useSync } from "@tui/context/sync"
|
||||||
import { useEvent } from "@tui/context/event"
|
import { useEvent } from "@tui/context/event"
|
||||||
import { SplitBorder } from "@tui/component/border"
|
import { SplitBorder } from "@tui/component/border"
|
||||||
import { Spinner } from "@tui/component/spinner"
|
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 { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||||
import type {
|
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)
|
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
|
||||||
})
|
})
|
||||||
const summary = createMemo(() => reasoningSummary(content()))
|
const summary = createMemo(() => reasoningSummary(content()))
|
||||||
const syntax = createMemo(() => generateSubtleSyntax(theme))
|
const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme))
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
if (!inMinimal()) return
|
if (!inMinimal()) return
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
|
import { RGBA, type BoxRenderable } from "@opentui/core"
|
||||||
import { testRender, useRenderer } from "@opentui/solid"
|
import { testRender, useRenderer } from "@opentui/solid"
|
||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
|
|
@ -16,7 +17,7 @@ import {
|
||||||
} from "@/cli/cmd/run/footer.command"
|
} from "@/cli/cmd/run/footer.command"
|
||||||
import { RunFooterView } from "@/cli/cmd/run/footer.view"
|
import { RunFooterView } from "@/cli/cmd/run/footer.view"
|
||||||
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
|
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 {
|
import type {
|
||||||
FooterState,
|
FooterState,
|
||||||
FooterSubagentState,
|
FooterSubagentState,
|
||||||
|
|
@ -153,6 +154,7 @@ async function renderFooter(
|
||||||
input: {
|
input: {
|
||||||
tuiConfig?: RunTuiConfig
|
tuiConfig?: RunTuiConfig
|
||||||
commands?: RunCommand[]
|
commands?: RunCommand[]
|
||||||
|
theme?: () => RunTheme
|
||||||
onCycle?: () => void
|
onCycle?: () => void
|
||||||
onSubmit?: (prompt: RunPrompt) => boolean
|
onSubmit?: (prompt: RunPrompt) => boolean
|
||||||
} = {},
|
} = {},
|
||||||
|
|
@ -183,7 +185,7 @@ async function renderFooter(
|
||||||
state={state}
|
state={state}
|
||||||
view={view}
|
view={view}
|
||||||
subagent={subagents}
|
subagent={subagents}
|
||||||
theme={RUN_THEME_FALLBACK}
|
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
||||||
tuiConfig={config}
|
tuiConfig={config}
|
||||||
backgroundSubagents={true}
|
backgroundSubagents={true}
|
||||||
agent="opencode"
|
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 () => {
|
test("run entry content updates when live commit text changes", async () => {
|
||||||
const [commit, setCommit] = createSignal<StreamCommit>({
|
const [commit, setCommit] = createSignal<StreamCommit>({
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
|
|
@ -612,7 +639,7 @@ test("direct footer shows editable prompts and additional queued work while runn
|
||||||
queuedPrompts={() => [
|
queuedPrompts={() => [
|
||||||
{ messageID: "m-queued", partID: "p-queued", prompt: { text: "follow up", parts: [] } },
|
{ messageID: "m-queued", partID: "p-queued", prompt: { text: "follow up", parts: [] } },
|
||||||
]}
|
]}
|
||||||
theme={RUN_THEME_FALLBACK}
|
theme={() => RUN_THEME_FALLBACK}
|
||||||
tuiConfig={tuiConfig}
|
tuiConfig={tuiConfig}
|
||||||
backgroundSubagents={true}
|
backgroundSubagents={true}
|
||||||
agent="opencode"
|
agent="opencode"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { afterEach, expect, test } from "bun:test"
|
import { afterEach, expect, test } from "bun:test"
|
||||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
|
import { RGBA, SyntaxStyle } from "@opentui/core"
|
||||||
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
|
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
|
||||||
import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface"
|
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"
|
import type { StreamCommit } from "@/cli/cmd/run/types"
|
||||||
|
|
||||||
type ClaimedCommit = {
|
type ClaimedCommit = {
|
||||||
|
|
@ -62,6 +63,8 @@ async function setup(
|
||||||
input: {
|
input: {
|
||||||
width?: number
|
width?: number
|
||||||
wrote?: boolean
|
wrote?: boolean
|
||||||
|
theme?: RunTheme
|
||||||
|
onThemeRelease?: (theme: RunTheme) => void
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const out = await createTestRenderer({
|
const out = await createTestRenderer({
|
||||||
|
|
@ -78,9 +81,10 @@ async function setup(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
renderer: out.renderer,
|
renderer: out.renderer,
|
||||||
scrollback: new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
|
scrollback: new RunScrollbackStream(out.renderer, input.theme ?? RUN_THEME_FALLBACK, {
|
||||||
treeSitterClient,
|
treeSitterClient,
|
||||||
wrote: input.wrote ?? false,
|
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 {
|
function user(text: string): StreamCommit {
|
||||||
return {
|
return {
|
||||||
kind: "user",
|
kind: "user",
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
test("keeps dark surfaces neutral on saturated backgrounds", () => {
|
||||||
const theme = resolveTheme(
|
const theme = resolveTheme(
|
||||||
generateSystem(
|
generateSystem(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { expect, test } from "bun:test"
|
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"
|
"../../../src/cli/cmd/tui/context/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -49,3 +50,27 @@ test("resolveTheme rejects circular color refs", () => {
|
||||||
|
|
||||||
expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
|
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()
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue