From dee2364103c71b87a0903730e16cbfe8ee86c7de Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Mon, 28 Sep 2026 13:12:02 -0400 Subject: [PATCH 1/2] fix(theme): refresh terminal colors via direct OSC probe Live terminal-color refresh (?997 notifications, SIGWINCH, and the mount probe) now writes Hunk's startup OSC 10/11/4 query plus a DA1 fence through the renderer and collects replies with OpenTUI's subscribeOsc, instead of clearPaletteCache() + getPalette(). A probe ends when the terminal has answered rather than after OpenTUI's 300 ms idle timeout, still picks up terminals answering after 300 ms, and a notification during an in-flight probe discards it and re-probes, so rapid switches no longer settle on a mix of old and new colors. OpenTUI's theme_mode event is an extra trigger. Probes skip the DA1 fence during OpenTUI's startup capability window, keep an owed fence after a timeout, and fall back to getPalette() if the renderer's private writeOut disappears. --- .changeset/fast-terminal-color-refresh.md | 5 + docs/themes.md | 9 +- packages/hunk/src/core/theme/detection.ts | 32 +- .../src/ui/theme/terminalColorWatcher.test.ts | 384 ++++++++++++++---- .../hunk/src/ui/theme/terminalColorWatcher.ts | 242 +++++++++-- test/pty/harness.ts | 66 +-- test/pty/terminal-theme-integration.test.ts | 142 ++++++- .../src/content/docs/docs/configure/themes.md | 2 +- 8 files changed, 735 insertions(+), 147 deletions(-) create mode 100644 .changeset/fast-terminal-color-refresh.md diff --git a/.changeset/fast-terminal-color-refresh.md b/.changeset/fast-terminal-color-refresh.md new file mode 100644 index 000000000..006f66a76 --- /dev/null +++ b/.changeset/fast-terminal-color-refresh.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Refresh the `terminal` theme about three times faster after a terminal color-scheme switch, pick up switches from terminals that answer slowly, and settle on the final palette instead of a mix of old and new colors after rapid switches. diff --git a/docs/themes.md b/docs/themes.md index f8b6e9d1a..31dff94a4 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -22,7 +22,14 @@ Hunk therefore matches whatever color scheme your terminal already uses. Hunk keeps following the terminal while it runs. When the terminal reports a color-scheme change (mode 2031, sent by Ghostty, kitty, herdr, and others) or Hunk receives `SIGWINCH` (what tmux-aware theme tools send after rewriting a -pane's colors), Hunk probes the palette again and repaints if it changed. +pane's colors), Hunk probes the palette again and repaints if it changed. The +probe ends as soon as the terminal has answered, so a responsive terminal +repaints about a tenth of a second after it switches, and slower terminals, +including ones answering over SSH, are picked up as long as they reply within a +second and a half. This works the same when Hunk reads a piped patch +(`git diff | hunk patch -`, `hunk pager`). If the terminal announces a switch +again while a probe is still answering, Hunk discards that probe and asks again, +so rapid switches settle on the final palette. Terminals that do not answer palette queries get a standard xterm-style palette for their light or dark background. diff --git a/packages/hunk/src/core/theme/detection.ts b/packages/hunk/src/core/theme/detection.ts index e82965cd2..aa1220c11 100644 --- a/packages/hunk/src/core/theme/detection.ts +++ b/packages/hunk/src/core/theme/detection.ts @@ -37,10 +37,26 @@ const OSC_COLOR_REPLY_PATTERN = new RegExp( `\\x1b\\](?:4;(\\d+)|(10)|(11));${OSC_COLOR_SPEC}${OSC_TERMINATOR}`, "gi", ); -// Terminals answer queries in order, so the primary device attributes reply marks the point where -// every color reply the terminal was ever going to send has already arrived. -const DEVICE_ATTRIBUTES_QUERY = "\x1b[c"; -const DEVICE_ATTRIBUTES_REPLY_PATTERN = /\x1b\[\?[\d;]*c/; +const DEVICE_ATTRIBUTES_REPLY_SOURCE = "\\x1b\\[\\?[\\d;]*c"; +const DEVICE_ATTRIBUTES_REPLY_PATTERN = new RegExp(DEVICE_ATTRIBUTES_REPLY_SOURCE); +const DEVICE_ATTRIBUTES_REPLY_SEQUENCE_PATTERN = new RegExp(`^${DEVICE_ATTRIBUTES_REPLY_SOURCE}$`); + +/** + * Primary device attributes (DA1) query. Terminals answer queries in order, so writing it after + * color queries makes its reply mark the point where every color reply the terminal was ever + * going to send has already arrived. + */ +export const DEVICE_ATTRIBUTES_QUERY = "\x1b[c"; + +/** OSC 10 (foreground), OSC 11 (background), and OSC 4 (16-color palette) queries, ST-terminated. */ +export const TERMINAL_COLOR_QUERY = + "\x1b]10;?\x1b\\\x1b]11;?\x1b\\" + + Array.from({ length: ANSI_PALETTE_SIZE }, (_, index) => `\x1b]4;${index};?\x1b\\`).join(""); + +/** Return whether one parsed terminal input sequence is a DA1 reply such as `ESC [ ? 62 ; 22 c`. */ +export function isDeviceAttributesReply(sequence: string) { + return DEVICE_ATTRIBUTES_REPLY_SEQUENCE_PATTERN.test(sequence); +} /** Convert xterm-style OSC color channels into 8-bit RGB. */ function parseHexChannel(channel: string) { @@ -121,7 +137,7 @@ export function themeModeForTerminalColors(colors: TerminalColors | null | undef } /** Return whether a probe learned anything at all about the terminal's colors. */ -function hasTerminalColors(colors: TerminalColors) { +export function hasTerminalColors(colors: TerminalColors) { return ( colors.foreground !== undefined || colors.background !== undefined || @@ -171,10 +187,6 @@ export async function detectTerminalColors({ input.resume?.(); input.on("data", onData); - const paletteQueries = Array.from( - { length: ANSI_PALETTE_SIZE }, - (_, index) => `\x1b]4;${index};?\x1b\\`, - ).join(""); - output.write(`\x1b]10;?\x1b\\\x1b]11;?\x1b\\${paletteQueries}${DEVICE_ATTRIBUTES_QUERY}`); + output.write(`${TERMINAL_COLOR_QUERY}${DEVICE_ATTRIBUTES_QUERY}`); }); } diff --git a/packages/hunk/src/ui/theme/terminalColorWatcher.test.ts b/packages/hunk/src/ui/theme/terminalColorWatcher.test.ts index f4183e472..b0accadb3 100644 --- a/packages/hunk/src/ui/theme/terminalColorWatcher.test.ts +++ b/packages/hunk/src/ui/theme/terminalColorWatcher.test.ts @@ -1,31 +1,67 @@ import { describe, expect, test } from "bun:test"; import { EventEmitter } from "node:events"; +import { RendererControlState } from "@opentui/core"; import type { TerminalColors } from "../../core/theme/terminalColors"; -import { sameTerminalColors, watchTerminalColors } from "./terminalColorWatcher"; - -const DARK_REPORT = { - palette: ["#000000", "#f7768e", "#9ece6a"], - defaultForeground: "#c0caf5", - defaultBackground: "#1a1b26", -}; -const LIGHT_REPORT = { - palette: ["#000000", "#d20f39", "#40a02b"], - defaultForeground: "#4c4f69", - defaultBackground: "#eff1f5", -}; - -/** Build a fake renderer whose palette probe answers with whatever report is current. */ -function createTestPaletteRenderer(report: { current: typeof DARK_REPORT }) { +import { rendererRawWriter, sameTerminalColors, watchTerminalColors } from "./terminalColorWatcher"; + +const DA1_REPLY = "\x1b[?62;22c"; +const DARK = { foreground: "#c0caf5", background: "#1a1b26", ansi: "#f7768e" }; +const LIGHT = { foreground: "#4c4f69", background: "#eff1f5", ansi: "#d20f39" }; + +/** Encode a #rrggbb color the way xterm answers OSC color queries. */ +function createTestRgbSpec(color: string) { + const channels = [1, 3, 5].map((index) => color.slice(index, index + 2)); + return `rgb:${channels.map((channel) => channel + channel).join("/")}`; +} + +/** Build every OSC color reply a terminal sends for one probe, without the DA1 fence. */ +function createTestColorReplies(colors: typeof DARK) { + return [ + `\x1b]10;${createTestRgbSpec(colors.foreground)}\x1b\\`, + `\x1b]11;${createTestRgbSpec(colors.background)}\x1b\\`, + ...Array.from({ length: 16 }, (_, index) => `\x1b]4;${index};${colors.ansi}\x07`), + ]; +} + +/** Hunk colors for a complete reply set. */ +function createTestTerminalColors(colors: typeof DARK): TerminalColors { + return { + foreground: colors.foreground, + background: colors.background, + palette: Array.from({ length: 16 }, () => colors.ansi), + }; +} + +/** + * Fake renderer that records probe writes and delivers terminal input the way OpenTUI does: OSC + * subscribers first, then input handlers until one consumes the sequence. + */ +function createTestColorRenderer(options: { palette?: TerminalColors } = {}) { + const events = new EventEmitter(); const handlers: ((sequence: string) => boolean)[] = []; + const subscribers = new Set<(sequence: string) => void>(); const renderer = { + writes: [] as string[], + consumed: [] as string[], probes: 0, cacheClears: 0, + isDestroyed: false, + controlState: RendererControlState.EXPLICIT_STARTED as RendererControlState, clearPaletteCache() { renderer.cacheClears += 1; }, async getPalette() { renderer.probes += 1; - return report.current as never; + const palette = options.palette ?? { palette: [] }; + return { + palette: palette.palette.map((color) => color ?? null), + defaultForeground: palette.foreground ?? null, + defaultBackground: palette.background ?? null, + } as never; + }, + subscribeOsc(handler: (sequence: string) => void) { + subscribers.add(handler); + return () => void subscribers.delete(handler); }, prependInputHandler(handler: (sequence: string) => boolean) { handlers.unshift(handler); @@ -33,83 +69,280 @@ function createTestPaletteRenderer(report: { current: typeof DARK_REPORT }) { removeInputHandler(handler: (sequence: string) => boolean) { handlers.splice(handlers.indexOf(handler), 1); }, - feed(sequence: string) { - return handlers.some((handler) => handler(sequence)); + on(event: "theme_mode", listener: () => void) { + events.on(event, listener); + }, + off(event: "theme_mode", listener: () => void) { + events.off(event, listener); }, + emitThemeMode() { + events.emit("theme_mode", "dark"); + }, + deliver(...sequences: string[]) { + for (const sequence of sequences) { + if (sequence.startsWith("\x1b]")) { + for (const subscriber of subscribers) subscriber(sequence); + } + if (handlers.some((handler) => handler(sequence))) renderer.consumed.push(sequence); + } + }, + subscribers, handlers, }; return renderer; } +type TestColorRenderer = ReturnType; + +/** Start a watcher over the fake renderer with short timings and fences active from mount. */ +function startTestWatcher( + renderer: TestColorRenderer, + options: { + initial?: TerminalColors; + write?: ((data: string) => void) | null; + probeTimeoutMs?: number; + fenceExpiryMs?: number; + startupCapabilityWindowMs?: number; + } = {}, +) { + let current = "initial" in options ? options.initial : { palette: [] }; + const changes: TerminalColors[] = []; + const signals = new EventEmitter(); + const dispose = watchTerminalColors({ + renderer, + current: () => current, + onChange: (colors) => { + current = colors; + changes.push(colors); + }, + signals, + write: + options.write === null + ? undefined + : (options.write ?? ((data) => renderer.writes.push(data))), + debounceMs: 5, + probeTimeoutMs: options.probeTimeoutMs ?? 200, + fenceExpiryMs: options.fenceExpiryMs ?? 1000, + startupCapabilityWindowMs: options.startupCapabilityWindowMs ?? 0, + }); + return { changes, dispose, signals }; +} + +/** Let the debounce elapse so a triggered probe is written. */ +const waitForTestDebounce = () => Bun.sleep(20); + describe("terminal color watcher", () => { - test("re-probes after a color-scheme notification and reports only changed colors", async () => { - const report = { current: DARK_REPORT }; - const renderer = createTestPaletteRenderer(report); - const signals = new EventEmitter(); - let current: TerminalColors | undefined; - const changes: TerminalColors[] = []; - const dispose = watchTerminalColors({ - renderer, - current: () => current, - onChange: (colors) => { - current = colors; - changes.push(colors); - }, - signals, - }); + test("probes after a notification and finishes on its DA1 fence", async () => { + const renderer = createTestColorRenderer(); + const { changes, dispose } = startTestWatcher(renderer); + renderer.deliver("\x1b[?997;1n", "\x1b[?997;1n"); // The notification stays unhandled so OpenTUI can still track light/dark from it. - expect(renderer.feed("\x1b[?997;1n")).toBe(false); - expect(renderer.feed("\x1b[?997;1n")).toBe(false); - await Bun.sleep(150); - expect(renderer.probes).toBe(1); - expect(renderer.cacheClears).toBe(1); + expect(renderer.consumed).toEqual([]); + await waitForTestDebounce(); + expect(renderer.writes).toHaveLength(1); + expect(renderer.writes[0]).toStartWith("\x1b]10;?\x1b\\\x1b]11;?\x1b\\\x1b]4;0;?\x1b\\"); + expect(renderer.writes[0]).toEndWith("\x1b]4;15;?\x1b\\\x1b[c"); + + renderer.deliver(...createTestColorReplies(DARK).slice(0, 2), DA1_REPLY); expect(changes).toEqual([ - { foreground: "#c0caf5", background: "#1a1b26", palette: ["#000000", "#f7768e", "#9ece6a"] }, + { foreground: DARK.foreground, background: DARK.background, palette: [] }, ]); + expect(renderer.consumed).toEqual([DA1_REPLY]); + expect(renderer.subscribers.size).toBe(0); + + dispose(); + expect(renderer.handlers).toHaveLength(0); + }); + + test("finishes once every color is answered but holds the next probe until the fence returns", async () => { + const renderer = createTestColorRenderer(); + const { changes, dispose, signals } = startTestWatcher(renderer); - // A resize with unchanged colors probes but reports nothing. signals.emit("SIGWINCH"); - await Bun.sleep(150); - expect(renderer.probes).toBe(2); - expect(changes).toHaveLength(1); + await waitForTestDebounce(); + renderer.deliver(...createTestColorReplies(DARK)); + expect(changes).toEqual([createTestTerminalColors(DARK)]); - // tmux-style: new OSC colors land in the pane, then SIGWINCH arrives. - report.current = LIGHT_REPORT; signals.emit("SIGWINCH"); - await Bun.sleep(150); - expect(changes.at(-1)).toMatchObject({ background: "#eff1f5", foreground: "#4c4f69" }); + await waitForTestDebounce(); + expect(renderer.writes).toHaveLength(1); + renderer.deliver(DA1_REPLY); + expect(renderer.consumed).toEqual([DA1_REPLY]); + expect(renderer.writes).toHaveLength(2); + // Unchanged colors are not reported again. + renderer.deliver(...createTestColorReplies(DARK), DA1_REPLY); + expect(changes).toHaveLength(1); dispose(); - expect(renderer.handlers).toHaveLength(0); - expect(signals.listenerCount("SIGWINCH")).toBe(0); }); - test("probes once at mount when startup never learned the terminal colors", async () => { - const renderer = createTestPaletteRenderer({ current: DARK_REPORT }); - const changes: TerminalColors[] = []; - const dispose = watchTerminalColors({ - renderer, - current: () => undefined, - onChange: (colors) => changes.push(colors), - signals: new EventEmitter(), - }); - await Bun.sleep(150); + test("discards a superseded probe and keeps its late replies out of the re-probe", async () => { + const renderer = createTestColorRenderer(); + const { changes, dispose } = startTestWatcher(renderer); + + renderer.deliver("\x1b[?997;1n"); + await waitForTestDebounce(); + // The scheme changes again while the first probe waits on a slow terminal. + renderer.deliver("\x1b[?997;2n"); + await waitForTestDebounce(); + expect(renderer.writes).toHaveLength(1); + + // The stale probe closes once its colors arrive but still owes its DA1, and a late reply to + // OpenTUI's own theme query lands before that fence. + renderer.deliver(...createTestColorReplies(DARK)); + renderer.deliver(`\x1b]11;${createTestRgbSpec(DARK.background)}\x07`); + expect(renderer.writes).toHaveLength(1); + renderer.deliver(DA1_REPLY); + expect(renderer.writes).toHaveLength(2); + expect(changes).toHaveLength(0); + + renderer.deliver(...createTestColorReplies(LIGHT).slice(0, 2), DA1_REPLY); + expect(changes).toEqual([ + { foreground: LIGHT.foreground, background: LIGHT.background, palette: [] }, + ]); dispose(); + }); + + test("keeps the current colors when the terminal only answers DA1", async () => { + const renderer = createTestColorRenderer(); + const { changes, dispose } = startTestWatcher(renderer); + + renderer.deliver("\x1b[?997;2n"); + await waitForTestDebounce(); + renderer.deliver(DA1_REPLY); + expect(changes).toHaveLength(0); + expect(renderer.subscribers.size).toBe(0); + dispose(); + }); + test("leaves foreign DA1 replies to OpenTUI during its startup capability window", async () => { + const renderer = createTestColorRenderer(); + const { changes, dispose } = startTestWatcher(renderer, { startupCapabilityWindowMs: 60 }); + + renderer.deliver("\x1b[?997;1n"); + await waitForTestDebounce(); + expect(renderer.writes[0]).not.toContain("\x1b[c"); + // OpenTUI's own startup DA1 neither closes the probe nor gets consumed. + renderer.deliver(DA1_REPLY); + expect(renderer.consumed).toEqual([]); + expect(renderer.subscribers.size).toBe(1); + renderer.deliver(...createTestColorReplies(DARK)); + expect(changes).toEqual([createTestTerminalColors(DARK)]); + + // Once the window closes, probes carry a fence and unrelated DA1 replies still pass through. + await Bun.sleep(80); + renderer.deliver(DA1_REPLY); + expect(renderer.consumed).toEqual([]); + renderer.deliver("\x1b[?997;1n"); + await waitForTestDebounce(); + expect(renderer.writes[1]).toEndWith("\x1b[c"); + dispose(); + }); + + test("keeps a timed-out probe's fence owed so a late DA1 cannot close the next probe", async () => { + const renderer = createTestColorRenderer(); + const { changes, dispose } = startTestWatcher(renderer, { probeTimeoutMs: 30 }); + + renderer.deliver("\x1b[?997;1n"); + await Bun.sleep(60); + expect(renderer.subscribers.size).toBe(0); + + renderer.deliver("\x1b[?997;2n"); + await waitForTestDebounce(); + expect(renderer.writes).toHaveLength(1); + // The first probe's DA1 finally arrives; it releases the next probe instead of closing it. + renderer.deliver(DA1_REPLY); + expect(renderer.writes).toHaveLength(2); + expect(renderer.subscribers.size).toBe(1); + + renderer.deliver(...createTestColorReplies(LIGHT).slice(0, 2), DA1_REPLY); + expect(changes).toEqual([ + { foreground: LIGHT.foreground, background: LIGHT.background, palette: [] }, + ]); + dispose(); + }); + + test("stops waiting for a fence the terminal never answers", async () => { + const renderer = createTestColorRenderer(); + const { changes, dispose } = startTestWatcher(renderer, { fenceExpiryMs: 40 }); + + renderer.deliver("\x1b[?997;1n"); + await waitForTestDebounce(); + renderer.deliver(...createTestColorReplies(DARK)); + renderer.deliver("\x1b[?997;2n"); + await waitForTestDebounce(); + expect(renderer.writes).toHaveLength(1); + + await Bun.sleep(50); + expect(renderer.writes).toHaveLength(2); + renderer.deliver(...createTestColorReplies(LIGHT)); + expect(changes.map((colors) => colors.background)).toEqual([DARK.background, LIGHT.background]); + dispose(); + }); + + test("does not probe while the renderer is suspended", async () => { + const renderer = createTestColorRenderer(); + renderer.controlState = RendererControlState.EXPLICIT_SUSPENDED; + const { dispose, signals } = startTestWatcher(renderer); + + signals.emit("SIGWINCH"); + await waitForTestDebounce(); + expect(renderer.writes).toHaveLength(0); + expect(renderer.subscribers.size).toBe(0); + dispose(); + }); + + test("falls back to OpenTUI's palette probe when the renderer cannot write raw bytes", async () => { + const renderer = createTestColorRenderer({ palette: createTestTerminalColors(DARK) }); + const { changes, dispose } = startTestWatcher(renderer, { write: null }); + + renderer.deliver("\x1b[?997;1n"); + await Bun.sleep(30); + expect(renderer.cacheClears).toBe(1); expect(renderer.probes).toBe(1); - expect(changes).toHaveLength(1); + expect(changes).toEqual([createTestTerminalColors(DARK)]); + dispose(); + }); + + test("probes when OpenTUI reports a theme mode without invalidating an in-flight probe", async () => { + const renderer = createTestColorRenderer(); + const { changes, dispose } = startTestWatcher(renderer); + + renderer.emitThemeMode(); + await waitForTestDebounce(); + expect(renderer.writes).toHaveLength(1); + + renderer.emitThemeMode(); + await waitForTestDebounce(); + renderer.deliver(...createTestColorReplies(DARK), DA1_REPLY); + expect(renderer.writes).toHaveLength(1); + expect(changes).toEqual([createTestTerminalColors(DARK)]); + + dispose(); + renderer.emitThemeMode(); + await waitForTestDebounce(); + expect(renderer.writes).toHaveLength(1); + }); + + test("probes once at mount when startup never learned the terminal colors", async () => { + const renderer = createTestColorRenderer(); + const { changes, dispose } = startTestWatcher(renderer, { initial: undefined }); + + await waitForTestDebounce(); + expect(renderer.writes).toHaveLength(1); + renderer.deliver(...createTestColorReplies(DARK), DA1_REPLY); + expect(changes).toEqual([createTestTerminalColors(DARK)]); + dispose(); }); - test("ignores unrelated input and compares reports by value", () => { - const renderer = createTestPaletteRenderer({ current: DARK_REPORT }); - const dispose = watchTerminalColors({ - renderer, - current: () => undefined, - onChange: () => undefined, - signals: new EventEmitter(), - }); - expect(renderer.feed("\x1b[A")).toBe(false); + test("ignores unrelated input and compares reports by value", async () => { + const renderer = createTestColorRenderer(); + const { dispose } = startTestWatcher(renderer); + renderer.deliver("\x1b[A", `\x1b]11;${createTestRgbSpec(DARK.background)}\x07`); + await waitForTestDebounce(); + expect(renderer.writes).toHaveLength(0); + expect(renderer.consumed).toEqual([]); dispose(); const colors = { foreground: "#ffffff", background: "#000000", palette: ["#111111"] }; @@ -117,4 +350,17 @@ describe("terminal color watcher", () => { expect(sameTerminalColors(colors, { ...colors, palette: ["#222222"] })).toBe(false); expect(sameTerminalColors(undefined, colors)).toBe(false); }); + + test("writes raw bytes through the renderer's own writeOut when it has one", () => { + const written: string[] = []; + const renderer = { + label: "renderer", + writeOut(this: { label: string }, data: string) { + written.push(`${this.label}:${data}`); + }, + }; + rendererRawWriter(renderer)?.("\x1b[c"); + expect(written).toEqual(["renderer:\x1b[c"]); + expect(rendererRawWriter({})).toBeUndefined(); + }); }); diff --git a/packages/hunk/src/ui/theme/terminalColorWatcher.ts b/packages/hunk/src/ui/theme/terminalColorWatcher.ts index de4861210..3e551d28d 100644 --- a/packages/hunk/src/ui/theme/terminalColorWatcher.ts +++ b/packages/hunk/src/ui/theme/terminalColorWatcher.ts @@ -1,4 +1,11 @@ -import type { CliRenderer } from "@opentui/core"; +import { type CliRenderer, RendererControlState } from "@opentui/core"; +import { + DEVICE_ATTRIBUTES_QUERY, + hasTerminalColors, + isDeviceAttributesReply, + parseTerminalColorReplies, + TERMINAL_COLOR_QUERY, +} from "../../core/theme/detection"; import { ANSI_PALETTE_SIZE, type TerminalColors } from "../../core/theme/terminalColors"; /** Color-scheme change notification a terminal sends after `CSI ? 2031 h` (dark or light). */ @@ -7,17 +14,53 @@ const COLOR_SCHEME_NOTIFICATION_PATTERN = /^\x1b\[\?997;[12]n$/; // Multiplexers write fresh OSC colors and then signal, and terminals can report a scheme change // before their reloaded palette settles, so collapse bursts into one probe. const REFRESH_DEBOUNCE_MS = 100; +// The DA1 fence ends a probe as soon as a responsive terminal has answered, so this only bounds +// terminals that skip some color queries or answer over a very slow link. +const PROBE_TIMEOUT_MS = 1500; +// How long an unanswered DA1 fence holds back the next probe. Past this, a very late DA1 could +// close a newer probe early, which only costs that probe some colors. +const FENCE_EXPIRY_MS = 5000; +// OpenTUI 0.5.6 writes its own DA1 only from `setupTerminal()` (including a DCS-wrapped retry for +// tmux once XTVERSION answers) and stops listening for capability replies 5 s later. The renderer +// is set up before Hunk mounts, so for this long after mounting a DA1 reply may be OpenTUI's. +const STARTUP_CAPABILITY_WINDOW_MS = 5000; -type PaletteSource = Pick< +type TerminalColorSource = Pick< CliRenderer, - "clearPaletteCache" | "getPalette" | "prependInputHandler" | "removeInputHandler" ->; + | "clearPaletteCache" + | "controlState" + | "getPalette" + | "isDestroyed" + | "prependInputHandler" + | "removeInputHandler" + | "subscribeOsc" +> & { + on(event: "theme_mode", listener: () => void): unknown; + off(event: "theme_mode", listener: () => void): unknown; +}; interface SignalSource { on(event: "SIGWINCH", listener: () => void): unknown; removeListener(event: "SIGWINCH", listener: () => void): unknown; } +/** + * Return a writer that sends raw bytes through the renderer's frame-ordered output, or undefined + * when this OpenTUI build does not have one. + * + * OpenTUI 0.5.6 declares `writeOut` private, but it is the path its own palette detector uses: + * on the threaded renderer it waits for an in-progress frame, so a query never lands inside one, + * which a direct `stdout.write` does not guarantee. OpenTUI has no public raw-write API yet, so + * the cast stays here and callers fall back to `getPalette()` if an upgrade removes it. + */ +export function rendererRawWriter(renderer: object): ((data: string) => void) | undefined { + const writeOut = (renderer as { writeOut?: unknown }).writeOut; + if (typeof writeOut !== "function") return undefined; + return (data) => { + writeOut.call(renderer, data); + }; +} + /** Convert OpenTUI's palette report into Hunk's terminal colors, or undefined when it is empty. */ export function terminalColorsFromPalette(report: { palette: (string | null)[]; @@ -29,11 +72,7 @@ export function terminalColorsFromPalette(report: { background: report.defaultBackground ?? undefined, palette: report.palette.slice(0, ANSI_PALETTE_SIZE).map((color) => color ?? undefined), }; - const answered = - colors.foreground !== undefined || - colors.background !== undefined || - colors.palette.some((color) => color !== undefined); - return answered ? colors : undefined; + return hasTerminalColors(colors) ? colors : undefined; } /** Return whether two terminal color reports describe the same colors. */ @@ -48,14 +87,52 @@ export function sameTerminalColors(left: TerminalColors | undefined, right: Term ); } +/** Copy every slot one parsed reply answered into the colors collected so far. */ +function mergeTerminalColors(into: TerminalColors, reply: TerminalColors) { + if (reply.foreground !== undefined) into.foreground = reply.foreground; + if (reply.background !== undefined) into.background = reply.background; + reply.palette.forEach((color, index) => { + if (color !== undefined) into.palette[index] = color; + }); +} + +/** Return whether a probe has an answer for every color it asked about. */ +function answeredEveryColorQuery(colors: TerminalColors) { + return ( + colors.foreground !== undefined && + colors.background !== undefined && + Array.from({ length: ANSI_PALETTE_SIZE }).every( + (_, index) => colors.palette[index] !== undefined, + ) + ); +} + +interface Probe { + /** A newer trigger arrived after this probe was written, so its answer may predate the change. */ + stale: boolean; + /** Finish with whatever the terminal has answered so far. */ + close(): void; +} + /** * Re-probe the terminal's colors whenever something says they may have changed, and report * reports that differ from the last one. * - * Two triggers cover how theme switches reach a running Hunk: terminals and multiplexers such as - * herdr send a mode 2031 color-scheme notification to panes, and tmux has no such notification so - * theme tooling rewrites each pane's OSC colors and then sends the pane SIGWINCH. SIGWINCH also - * fires on ordinary resizes; those probes simply find unchanged colors. + * Three triggers cover how theme switches reach a running Hunk: terminals and multiplexers such as + * herdr send a mode 2031 color-scheme notification to panes; tmux has no such notification, so + * theme tooling rewrites each pane's OSC colors and then sends the pane SIGWINCH; and OpenTUI's own + * `theme_mode` event fires when it learns light/dark after Hunk's startup probe came up empty. + * SIGWINCH also fires on ordinary resizes; those probes simply find unchanged colors. + * + * Each probe writes Hunk's startup color query plus a DA1 fence through the renderer and collects + * the OSC replies OpenTUI parses, so it finishes as soon as the terminal has answered instead of + * waiting out OpenTUI's palette idle timeout. Probes never overlap on the wire: a trigger while a + * probe is in flight discards that probe's answer and queues one fresh probe, and the next probe + * waits until the previous DA1 fence is answered, so replies owed to older queries cannot mix + * into it. The watcher consumes only DA1 replies it is owed, leaving the rest to OpenTUI. Until + * OpenTUI's startup capability window closes, a DA1 reply may be OpenTUI's own, so probes there + * skip the fence and finish once every color is answered or on the timeout. Without a raw + * writer, probes fall back to OpenTUI's slower `getPalette()`. * * Startup only probes when the configured theme follows the terminal. When it did not, probe once * right away so selecting the `terminal` theme later shows the real palette, not the fallback. @@ -65,48 +142,153 @@ export function watchTerminalColors({ current, onChange, signals = process, + write = rendererRawWriter(renderer), + debounceMs = REFRESH_DEBOUNCE_MS, + probeTimeoutMs = PROBE_TIMEOUT_MS, + fenceExpiryMs = FENCE_EXPIRY_MS, + startupCapabilityWindowMs = STARTUP_CAPABILITY_WINDOW_MS, }: { - renderer: PaletteSource; + renderer: TerminalColorSource; current: () => TerminalColors | undefined; onChange: (colors: TerminalColors) => void; signals?: SignalSource; + /** Raw terminal writer; without one the watcher falls back to OpenTUI's `getPalette()`. */ + write?: (data: string) => void; + debounceMs?: number; + probeTimeoutMs?: number; + fenceExpiryMs?: number; + startupCapabilityWindowMs?: number; }) { let timer: ReturnType | undefined; let disposed = false; + let probe: Probe | undefined; + let probeRequested = false; + // At most one DA1 fence of ours is ever unanswered; its expiry timer doubles as the flag. + let owedFence: ReturnType | undefined; + let fencesActive = startupCapabilityWindowMs <= 0; + const fenceWindow = fencesActive + ? undefined + : setTimeout(() => { + fencesActive = true; + }, startupCapabilityWindowMs); - const refresh = async () => { - timer = undefined; - try { - // OpenTUI caches the palette and only invalidates it when light/dark flips, which misses - // a switch between two dark themes. + const apply = (colors: TerminalColors | undefined) => { + if (!disposed && colors && !sameTerminalColors(current(), colors)) onChange(colors); + }; + + /** Start the next requested probe once nothing is in flight or owed on the wire. */ + const drain = () => { + if (disposed || !probeRequested || probe || owedFence) return; + probeRequested = false; + startProbe(); + }; + + const settleFence = () => { + if (owedFence) clearTimeout(owedFence); + owedFence = undefined; + }; + + const startProbe = () => { + // A suspended renderer (for example while an editor owns the terminal) must not write queries + // and drops input anyway; the next notification or resize after it resumes will probe. + if (renderer.isDestroyed || renderer.controlState === RendererControlState.EXPLICIT_SUSPENDED) { + return; + } + + let deadline: ReturnType | undefined; + let unsubscribe: (() => void) | undefined; + const collected: TerminalColors = { palette: [] }; + const finish = (colors: TerminalColors | undefined) => { + if (probe !== self) return; + probe = undefined; + if (deadline) clearTimeout(deadline); + unsubscribe?.(); + if (!self.stale) apply(colors); + drain(); + }; + const self: Probe = { + stale: false, + close: () => finish(hasTerminalColors(collected) ? collected : undefined), + }; + probe = self; + + if (!write) { + // OpenTUI caches the palette and only invalidates it when light/dark flips, which misses a + // switch between two dark themes. renderer.clearPaletteCache(); - const colors = terminalColorsFromPalette( - await renderer.getPalette({ size: ANSI_PALETTE_SIZE }), + renderer.getPalette({ size: ANSI_PALETTE_SIZE }).then( + (report) => finish(terminalColorsFromPalette(report)), + // A renderer that suspends mid-probe cannot answer; the next trigger after resume will. + () => finish(undefined), ); - if (!disposed && colors && !sameTerminalColors(current(), colors)) { - onChange(colors); - } + return; + } + + unsubscribe = renderer.subscribeOsc((sequence) => { + mergeTerminalColors(collected, parseTerminalColorReplies(sequence)); + if (answeredEveryColorQuery(collected)) self.close(); + }); + deadline = setTimeout(self.close, probeTimeoutMs); + + // During OpenTUI's startup capability window a DA1 reply may be OpenTUI's own, so probes there + // skip the fence and finish once every color is answered or on the timeout. + const fenced = fencesActive; + try { + write(fenced ? `${TERMINAL_COLOR_QUERY}${DEVICE_ATTRIBUTES_QUERY}` : TERMINAL_COLOR_QUERY); } catch { - // A suspended renderer (for example while an editor owns the terminal) cannot probe; the - // next notification or resize after it resumes will. + finish(undefined); + return; + } + if (fenced) { + // A probe that times out keeps its fence owed, so a late DA1 cannot close the next probe. + owedFence = setTimeout(() => { + owedFence = undefined; + drain(); + }, fenceExpiryMs); } }; + const requestProbe = () => { + timer = undefined; + probeRequested = true; + drain(); + }; + + /** Debounce a probe; a real change also marks any in-flight probe's answer as outdated. */ const schedule = () => { if (disposed) return; + if (probe) probe.stale = true; if (timer) clearTimeout(timer); - timer = setTimeout(() => void refresh(), REFRESH_DEBOUNCE_MS); + timer = setTimeout(requestProbe, debounceMs); }; const onSequence = (sequence: string) => { if (COLOR_SCHEME_NOTIFICATION_PATTERN.test(sequence)) { schedule(); + // Leave the notification for OpenTUI, which tracks light/dark from it too. + return false; + } + if (owedFence && isDeviceAttributesReply(sequence)) { + settleFence(); + probe?.close(); + drain(); + // OpenTUI re-parses every DA1 as a capability reply and forces a full repaint, so swallow + // the fences it never asked for. + return true; } - // Leave the notification for OpenTUI, which tracks light/dark from it too. return false; }; + // OpenTUI reports light/dark from its own OSC 10/11 query, answered before any probe of ours + // that the same change triggered, so it only asks for a probe and never invalidates one. + const onThemeMode = () => { + if (!timer && !probe && !probeRequested) schedule(); + }; + + // OSC replies reach subscribers before input handlers, and handlers never see a probe's colors + // as keys, so the watcher only needs to intercept notifications and its own fences. renderer.prependInputHandler(onSequence); + renderer.on("theme_mode", onThemeMode); signals.on("SIGWINCH", schedule); if (current() === undefined) { schedule(); @@ -115,7 +297,11 @@ export function watchTerminalColors({ return () => { disposed = true; if (timer) clearTimeout(timer); + if (fenceWindow) clearTimeout(fenceWindow); + probe?.close(); + settleFence(); renderer.removeInputHandler(onSequence); + renderer.off("theme_mode", onThemeMode); signals.removeListener("SIGWINCH", schedule); }; } diff --git a/test/pty/harness.ts b/test/pty/harness.ts index dee61743a..3fef935e5 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -291,6 +291,26 @@ function runGit(args: string[], cwd: string, allowExitCodeOne = false) { return proc.stdout; } +/** + * Start one PTY session. With a responder, subscribe before the child writes anything so startup + * terminal queries are answered, then wait for the first frame; otherwise use tuistory's launcher. + */ +async function startTestTerminalSession( + tuistory: Awaited>, + launchOptions: ConstructorParameters>["Session"]>[0], + testTerminalResponder?: (data: string, session: Session) => void, +) { + if (!testTerminalResponder) { + return tuistory.launchTerminal(launchOptions); + } + + const session = new tuistory.Session(launchOptions); + session.subscribe((data) => testTerminalResponder(data, session)); + await session.waitForData({ timeout: 5_000 }); + await session.waitIdle(); + return session; +} + /** Build a fresh PTY test helper that tracks its own temp directories for one integration test file. */ export function createPtyHarness() { const tempDirs: string[] = []; @@ -1081,15 +1101,7 @@ end }, }; - if (!options.testTerminalResponder) { - return tuistory.launchTerminal(launchOptions); - } - - const session = new tuistory.Session(launchOptions); - session.subscribe((data) => options.testTerminalResponder?.(data, session)); - await session.waitForData({ timeout: 5_000 }); - await session.waitIdle(); - return session; + return startTestTerminalSession(tuistory, launchOptions, options.testTerminalResponder); } /** Launch an arbitrary shell command inside the PTY for pipeline-style integration tests. */ @@ -1099,24 +1111,30 @@ end cols?: number; rows?: number; env?: Record; + /** Answer terminal queries from the start, as `launchHunk` does. */ + testTerminalResponder?: (data: string, session: Session) => void; }) { - const { launchTerminal } = await loadTuistory(); + const tuistory = await loadTuistory(); - return launchTerminal({ - command: "/bin/bash", - idleDelayMs: tuistoryIdleDelayMs, - args: ["-c", options.command], - cwd: options.cwd ?? repoRoot, - cols: options.cols ?? 140, - rows: options.rows ?? 24, - env: { - ...process.env, - XDG_CONFIG_HOME: configHome(), - HUNK_MCP_DISABLE: "1", - HUNK_DISABLE_UPDATE_NOTICE: "1", - ...options.env, + return startTestTerminalSession( + tuistory, + { + command: "/bin/bash", + idleDelayMs: tuistoryIdleDelayMs, + args: ["-c", options.command], + cwd: options.cwd ?? repoRoot, + cols: options.cols ?? 140, + rows: options.rows ?? 24, + env: { + ...process.env, + XDG_CONFIG_HOME: configHome(), + HUNK_MCP_DISABLE: "1", + HUNK_DISABLE_UPDATE_NOTICE: "1", + ...options.env, + }, }, - }); + options.testTerminalResponder, + ); } /** diff --git a/test/pty/terminal-theme-integration.test.ts b/test/pty/terminal-theme-integration.test.ts index 74aee48e7..c96399bb7 100644 --- a/test/pty/terminal-theme-integration.test.ts +++ b/test/pty/terminal-theme-integration.test.ts @@ -4,7 +4,7 @@ import { createPtyHarness, sleep } from "./harness"; const harness = createPtyHarness(); -setDefaultTimeout(20_000); +setDefaultTimeout(30_000); afterEach(() => { harness.cleanup(); @@ -63,13 +63,22 @@ const PALETTE_B: TestTerminalPalette = { }; const TERMINAL_QUERY_PATTERN = /\x1b\](?:4;(\d+)|(\d+));\?(?:\x07|\x1b\\)|\x1b\[c/g; +// OpenTUI's startup capability window, during which Hunk's live probes skip their DA1 fence. +const STARTUP_CAPABILITY_WINDOW_MS = 5_000; -/** Answer Hunk and OpenTUI color queries from a mutable terminal palette. */ +/** + * Answer Hunk and OpenTUI color queries from a mutable terminal palette, like a terminal that + * answers OSC 10/11/12 and OSC 4 but not OSC 13-19. Replies keep query order even when delayed, + * and each reply uses the palette current when its query arrived. + */ function createTestTerminalResponder(initialPalette: TestTerminalPalette) { let palette = initialPalette; let buffer = ""; + let pending = Promise.resolve(); + const state = { replyDelayMs: 0, paletteRepliesSent: 0 }; return { + state, setPalette(nextPalette: TestTerminalPalette) { palette = nextPalette; }, @@ -88,6 +97,7 @@ function createTestTerminalResponder(initialPalette: TestTerminalPalette) { const color = palette.ansi[paletteIndex]; if (color) response += `\x1b]4;${paletteIndex};${color}\x07`; } else if (specialIndex !== undefined) { + if (specialIndex > 12) continue; const color = specialIndex === 11 ? palette.background : palette.foreground; response += `\x1b]${specialIndex};${color}\x07`; } else { @@ -96,7 +106,13 @@ function createTestTerminalResponder(initialPalette: TestTerminalPalette) { } buffer = buffer.slice(consumedThrough).slice(-64); - if (response) session.writeRaw(response); + if (!response) return; + const delayMs = state.replyDelayMs; + const send = () => { + session.writeRaw(response); + if (response.includes("\x1b]4;")) state.paletteRepliesSent += 1; + }; + pending = pending.then(() => (delayMs > 0 ? sleep(delayMs).then(send) : send())); }, }; } @@ -117,31 +133,129 @@ async function waitForForeground(session: Session, color: string, needle: string ); } +/** Wait until a condition over the responder's state holds. */ +async function waitForResponder(predicate: () => boolean) { + const deadline = Date.now() + 10_000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("Timed out waiting for the terminal responder."); + await sleep(5); + } +} + +/** Assert that palette A no longer paints the review and palette B fully does. */ +async function expectOnlyPaletteB(session: Session, needles: { keyword: string; text: string }) { + expect(await waitForForeground(session, PALETTE_B.ansi[5]!, needles.keyword)).toContain( + needles.keyword, + ); + expect(await waitForForeground(session, PALETTE_B.foreground, needles.text)).toContain( + needles.text, + ); + // Let any in-flight probe finish before checking nothing reverted or kept the old palette. + await sleep(400); + expect( + await session.text({ immediate: true, only: { foreground: PALETTE_A.ansi[5]! } }), + ).not.toContain(needles.keyword); + expect( + await session.text({ immediate: true, only: { foreground: PALETTE_B.ansi[5]! } }), + ).toContain(needles.keyword); +} + +/** Launch `hunk diff` on the agent file pair with a TTY stdin. */ +async function launchTestDiffSession(terminal: ReturnType) { + const fixture = harness.createAgentFilePair(); + const session = await harness.launchHunk({ + args: ["diff", "--files", fixture.before, fixture.after, "--mode", "unified"], + cwd: fixture.dir, + cols: 100, + rows: 24, + testTerminalResponder: terminal.respond, + }); + await session.waitForText("export const answer = 42;"); + await waitForForeground(session, PALETTE_A.ansi[5]!, "export"); + await waitForForeground(session, PALETTE_A.foreground, "answer"); + return session; +} + +const DIFF_NEEDLES = { keyword: "export", text: "answer" }; + describe("PTY terminal theme", () => { test("repaints from a fresh terminal palette after a dark-to-dark color-scheme notification", async () => { - const fixture = harness.createAgentFilePair(); const terminal = createTestTerminalResponder(PALETTE_A); - const session = await harness.launchHunk({ - args: ["diff", "--files", fixture.before, fixture.after, "--mode", "unified"], + const session = await launchTestDiffSession(terminal); + + try { + terminal.setPalette(PALETTE_B); + session.writeRaw("\x1b[?997;1n"); + await expectOnlyPaletteB(session, DIFF_NEEDLES); + } finally { + session.close(); + } + }); + + test("repaints a piped `patch -` review after a color-scheme notification", async () => { + const fixture = harness.createPagerPatchFixture(8); + const terminal = createTestTerminalResponder(PALETTE_A); + const session = await harness.launchShellCommand({ + command: `cat ${harness.shellQuote(fixture.patchFile)} | exec ${harness.buildHunkCommand([ + "patch", + "-", + "--mode", + "unified", + ])}`, cwd: fixture.dir, cols: 100, rows: 24, testTerminalResponder: terminal.respond, }); + const needles = { keyword: "export", text: "after_01" }; try { - await session.waitForText("export const answer = 42;"); - expect(await waitForForeground(session, PALETTE_A.ansi[5]!, "export")).toContain("const"); - expect(await waitForForeground(session, PALETTE_A.foreground, "answer")).toContain("added"); + await session.waitForText("after_01"); + await waitForForeground(session, PALETTE_A.ansi[5]!, needles.keyword); + await waitForForeground(session, PALETTE_A.foreground, needles.text); + terminal.setPalette(PALETTE_B); + session.writeRaw("\x1b[?997;1n"); + await expectOnlyPaletteB(session, needles); + } finally { + session.close(); + } + }); + + test("ends on the new palette when a stale notification races the real switch", async () => { + const terminal = createTestTerminalResponder(PALETTE_A); + const session = await launchTestDiffSession(terminal); + + try { + // The terminal announces the switch before its palette changes, so the first probe is + // answered slowly with the old colors; only then does it switch and notify again. + terminal.state.replyDelayMs = 100; + const answeredBefore = terminal.state.paletteRepliesSent; + session.writeRaw("\x1b[?997;1n"); + await waitForResponder(() => terminal.state.paletteRepliesSent > answeredBefore); + terminal.setPalette(PALETTE_B); + terminal.state.replyDelayMs = 0; + session.writeRaw("\x1b[?997;1n"); + + await expectOnlyPaletteB(session, DIFF_NEEDLES); + } finally { + session.close(); + } + }); + + test("picks up a switch from a terminal that answers slowly once DA1 fences are active", async () => { + const terminal = createTestTerminalResponder(PALETTE_A); + const session = await launchTestDiffSession(terminal); + + try { + await sleep(STARTUP_CAPABILITY_WINDOW_MS); + terminal.state.replyDelayMs = 400; terminal.setPalette(PALETTE_B); session.writeRaw("\x1b[?997;1n"); - expect(await waitForForeground(session, PALETTE_B.ansi[5]!, "export")).toContain("const"); - expect(await waitForForeground(session, PALETTE_B.foreground, "answer")).toContain("added"); - expect( - await session.text({ immediate: true, only: { foreground: PALETTE_A.ansi[5]! } }), - ).not.toContain("export"); + await expectOnlyPaletteB(session, DIFF_NEEDLES); + // Neither the replies nor the consumed DA1 fence reach the screen. + expect(await session.text({ immediate: true })).not.toMatch(/62;22c|rgb:|\]4;/); } finally { session.close(); } diff --git a/website/src/content/docs/docs/configure/themes.md b/website/src/content/docs/docs/configure/themes.md index 3d9c56b5f..4b85fb0d9 100644 --- a/website/src/content/docs/docs/configure/themes.md +++ b/website/src/content/docs/docs/configure/themes.md @@ -3,7 +3,7 @@ title: Themes description: Choose a built-in Shiki-backed theme, detect terminal background, or define precise color overrides. --- -By default Hunk uses the `terminal` theme, which follows your terminal's own colors: at startup Hunk reads the terminal's foreground, background, and 16-color ANSI palette, and draws diffs, chrome, and syntax highlighting from them. Hunk keeps following the terminal while it runs: when the terminal reports a color-scheme change (mode 2031, sent by Ghostty, kitty, herdr, and others) or Hunk receives `SIGWINCH` (what tmux-aware theme tools send after rewriting a pane's colors), it probes the palette again and repaints. Terminals that do not answer palette queries get a standard xterm-style palette for their light or dark background. +By default Hunk uses the `terminal` theme, which follows your terminal's own colors: at startup Hunk reads the terminal's foreground, background, and 16-color ANSI palette, and draws diffs, chrome, and syntax highlighting from them. Hunk keeps following the terminal while it runs: when the terminal reports a color-scheme change (mode 2031, sent by Ghostty, kitty, herdr, and others) or Hunk receives `SIGWINCH` (what tmux-aware theme tools send after rewriting a pane's colors), it probes the palette again and repaints, usually within a tenth of a second and for terminals that take up to a second and a half to answer, including when Hunk reads a piped patch. Terminals that do not answer palette queries get a standard xterm-style palette for their light or dark background. Press `t` in Hunk or choose **View → Themes…** to preview and select a different theme. Persist it in TOML: From 90fc0e0d5beb6c369bba7ff5fbe72be24f8beb2f Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Tue, 6 Oct 2026 15:56:54 -0400 Subject: [PATCH 2/2] fix(theme): settle the palette fallback probe when clearing its cache throws --- .../src/ui/theme/terminalColorWatcher.test.ts | 21 +++++++++++++++++++ .../hunk/src/ui/theme/terminalColorWatcher.ts | 8 ++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/hunk/src/ui/theme/terminalColorWatcher.test.ts b/packages/hunk/src/ui/theme/terminalColorWatcher.test.ts index b0accadb3..5afd5b0a2 100644 --- a/packages/hunk/src/ui/theme/terminalColorWatcher.test.ts +++ b/packages/hunk/src/ui/theme/terminalColorWatcher.test.ts @@ -305,6 +305,27 @@ describe("terminal color watcher", () => { dispose(); }); + test("keeps probing after the palette fallback fails to clear its cache", async () => { + const renderer = createTestColorRenderer({ palette: createTestTerminalColors(DARK) }); + const clearPaletteCache = renderer.clearPaletteCache; + renderer.clearPaletteCache = () => { + renderer.clearPaletteCache = clearPaletteCache; + throw new Error("renderer is suspending"); + }; + const { changes, dispose } = startTestWatcher(renderer, { write: null }); + + renderer.deliver("\x1b[?997;1n"); + await Bun.sleep(30); + expect(renderer.probes).toBe(0); + expect(changes).toEqual([]); + + renderer.deliver("\x1b[?997;1n"); + await Bun.sleep(30); + expect(renderer.probes).toBe(1); + expect(changes).toEqual([createTestTerminalColors(DARK)]); + dispose(); + }); + test("probes when OpenTUI reports a theme mode without invalidating an in-flight probe", async () => { const renderer = createTestColorRenderer(); const { changes, dispose } = startTestWatcher(renderer); diff --git a/packages/hunk/src/ui/theme/terminalColorWatcher.ts b/packages/hunk/src/ui/theme/terminalColorWatcher.ts index 3e551d28d..74ae4b4a8 100644 --- a/packages/hunk/src/ui/theme/terminalColorWatcher.ts +++ b/packages/hunk/src/ui/theme/terminalColorWatcher.ts @@ -215,7 +215,13 @@ export function watchTerminalColors({ if (!write) { // OpenTUI caches the palette and only invalidates it when light/dark flips, which misses a // switch between two dark themes. - renderer.clearPaletteCache(); + try { + renderer.clearPaletteCache(); + } catch { + // Settle the probe so a throwing renderer cannot leave the watcher wedged on it. + finish(undefined); + return; + } renderer.getPalette({ size: ANSI_PALETTE_SIZE }).then( (report) => finish(terminalColorsFromPalette(report)), // A renderer that suspends mid-probe cannot answer; the next trigger after resume will.