From a583b1ffda108c6a60a573b53fa20451c37f3b41 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:46:47 +0900 Subject: [PATCH] fix(tui): inherit terminal background --- tui/scripts/pty_smoke.py | 10 ++++++++ tui/src/app.test.ts | 55 ++++++++++++++++++++++++++++++---------- tui/src/app.ts | 27 ++++++++------------ 3 files changed, 63 insertions(+), 29 deletions(-) diff --git a/tui/scripts/pty_smoke.py b/tui/scripts/pty_smoke.py index 32dfca73e..6c616d48e 100644 --- a/tui/scripts/pty_smoke.py +++ b/tui/scripts/pty_smoke.py @@ -144,6 +144,16 @@ def main() -> int: raise AssertionError(f"TUI did not render committed input {glyph!r}") if "Traceback" in text or "Task exception was never retrieved" in text: raise AssertionError("TUI emitted an exception during shutdown") + # The UI must inherit the host terminal background. Fixed RGB/indexed + # surfaces become black strips in embedded terminals after long output. + for escape in ( + b"\x1b[48;2;14;15;17m", + b"\x1b[48;2;23;24;27m", + b"\x1b[48;5;233m", + b"\x1b[48;5;234m", + ): + if escape in output: + raise AssertionError(f"TUI painted a fixed background: {escape!r}") print("PTY smoke test passed: Unicode input, resize, and terminal restoration") return 0 diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 4972d7c25..daaf3b724 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -215,6 +215,35 @@ describe("NanobotTui layout", () => { } }) + test("inherits the host background after long output fills the viewport", async () => { + setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" }) + const app = mount(setup) + app.accept({ event: "attached", chat_id: "chat" }) + app.accept({ + event: "delta", + chat_id: "chat", + text: Array.from({ length: 80 }, (_, index) => ( + `### Section ${index + 1}\n中文长回答、**bold** and [link](https://nanobot.test/${index + 1})` + )).join("\n\n"), + }) + app.accept({ event: "stream_end", chat_id: "chat" }) + app.accept({ event: "turn_end", chat_id: "chat" }) + await setup.flush() + + const internals = app as unknown as { + shell: { backgroundColor: { intent: string } } + composerFrame: { backgroundColor: { intent: string } } + composer: { backgroundColor: { intent: string } } + } + const spans = setup.captureSpans().lines.flatMap((line) => line.spans) + + expect(internals.shell.backgroundColor.intent).toBe("default") + expect(internals.composerFrame.backgroundColor.intent).toBe("default") + expect(internals.composer.backgroundColor.intent).toBe("default") + expect(spans.length).toBeGreaterThan(0) + expect(spans.every((span) => span.bg.intent === "default")).toBe(true) + }) + test("rethemes the complete retained interface when the terminal appearance changes", async () => { setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" }) const app = mount(setup) @@ -225,9 +254,9 @@ describe("NanobotTui layout", () => { await setup.renderOnce() const internals = app as unknown as { - palette: { background: string; text: string; border: string } - shell: { backgroundColor: { toInts(): number[] } } - composer: { backgroundColor: { toInts(): number[] }; textColor: { toInts(): number[] } } + palette: { referenceBackground: string; text: string; border: string } + shell: { backgroundColor: { intent: string } } + composer: { backgroundColor: { intent: string }; textColor: { toInts(): number[] } } transcript: { frames: Set<{ borderColor: { toInts(): number[] } }> markdown: Set<{ syntaxStyle: object }> @@ -240,12 +269,12 @@ describe("NanobotTui layout", () => { await setup.flush() expect(internals.palette).toMatchObject({ - background: "#FAFAFA", + referenceBackground: "#FAFAFA", text: "#18181B", border: "#D4D4D8", }) - expect(internals.shell.backgroundColor.toInts().slice(0, 3)).toEqual([250, 250, 250]) - expect(internals.composer.backgroundColor.toInts().slice(0, 3)).toEqual([244, 244, 245]) + expect(internals.shell.backgroundColor.intent).toBe("default") + expect(internals.composer.backgroundColor.intent).toBe("default") expect(internals.composer.textColor.toInts().slice(0, 3)).toEqual([24, 24, 27]) expect([...internals.transcript.frames][0]?.borderColor.toInts().slice(0, 3)).toEqual([ 212, 212, 216, @@ -261,7 +290,7 @@ describe("NanobotTui layout", () => { client(), new MockTreeSitterClient({ autoResolveTimeout: 0 }), ) - const internals = app as unknown as { palette: { background: string } } + const internals = app as unknown as { palette: { referenceBackground: string } } Object.defineProperty(setup.renderer, "themeMode", { configurable: true, value: "dark" }) await app.start() @@ -269,7 +298,7 @@ describe("NanobotTui layout", () => { setup.renderer.emit(CliRenderEvents.THEME_MODE, "dark") await setup.renderOnce() - expect(internals.palette.background).toBe("#FAFAFA") + expect(internals.palette.referenceBackground).toBe("#FAFAFA") }) test("waits for automatic terminal detection before connecting or painting", async () => { @@ -296,7 +325,7 @@ describe("NanobotTui layout", () => { resolveMode("light") await starting expect(connected).toBe(true) - expect((app as unknown as { palette: { background: string } }).palette.background).toBe("#FAFAFA") + expect((app as unknown as { palette: { referenceBackground: string } }).palette.referenceBackground).toBe("#FAFAFA") }) test("falls back to the dark palette when terminal theme probing has no answer", async () => { @@ -316,20 +345,20 @@ describe("NanobotTui layout", () => { await app.start() expect(connected).toBe(true) - expect((app as unknown as { palette: { background: string } }).palette.background).toBe("#0E0F11") + expect((app as unknown as { palette: { referenceBackground: string } }).palette.referenceBackground).toBe("#0E0F11") }) test("keeps semantic colors legible in both terminal appearances", async () => { setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) const app = mount(setup) const internals = app as unknown as { - palette: Record & { background: string; panel: string; faint: string } + palette: Record & { referenceBackground: string; faint: string } } const assertContrast = () => { for (const tone of ["text", "muted", "accent", "success", "error", "user", "warm", "cool"]) { - expect(contrastRatio(internals.palette[tone] ?? "", internals.palette.panel)).toBeGreaterThanOrEqual(4.5) + expect(contrastRatio(internals.palette[tone] ?? "", internals.palette.referenceBackground)).toBeGreaterThanOrEqual(4.5) } - expect(contrastRatio(internals.palette.faint, internals.palette.panel)).toBeGreaterThanOrEqual(3) + expect(contrastRatio(internals.palette.faint, internals.palette.referenceBackground)).toBeGreaterThanOrEqual(3) } assertContrast() diff --git a/tui/src/app.ts b/tui/src/app.ts index cb5632050..3223f722f 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -41,8 +41,7 @@ interface ChatClient { } interface Palette { - background: string - panel: string + referenceBackground: string text: string muted: string faint: string @@ -56,8 +55,7 @@ interface Palette { } const DARK: Palette = { - background: "#0E0F11", - panel: "#17181B", + referenceBackground: "#0E0F11", text: "#ECEDEE", muted: "#A1A1AA", faint: "#71717A", @@ -71,8 +69,7 @@ const DARK: Palette = { } const LIGHT: Palette = { - background: "#FAFAFA", - panel: "#F4F4F5", + referenceBackground: "#FAFAFA", text: "#18181B", muted: "#6F6F78", faint: "#8A8A94", @@ -196,7 +193,10 @@ export class NanobotTui { onStatus: (status, detail) => this.handleStatus(status, detail), }) - this.renderer.setBackgroundColor(this.palette.background) + // The terminal owns its canvas. Keeping the default-background intent is + // essential in embedded terminals, where painting our own near-black RGB + // only colors occupied cells and turns long output into dark strips. + this.renderer.setBackgroundColor(RGBA.defaultBackground()) this.shell = new BoxRenderable(renderer, { id: "nanobot-tui-footer", width: "100%", @@ -204,7 +204,7 @@ export class NanobotTui { paddingLeft: 1, paddingRight: 1, flexDirection: "column", - backgroundColor: this.palette.background, + backgroundColor: RGBA.defaultBackground(), }) this.title = new TextRenderable(renderer, { id: "nanobot-tui-title", @@ -223,7 +223,7 @@ export class NanobotTui { borderColor: this.palette.border, paddingLeft: 1, paddingRight: 1, - backgroundColor: this.palette.panel, + backgroundColor: RGBA.defaultBackground(), }) this.composer = new TextareaRenderable(renderer, { id: "nanobot-tui-composer", @@ -235,8 +235,8 @@ export class NanobotTui { placeholderColor: this.palette.faint, textColor: this.palette.text, focusedTextColor: this.palette.text, - backgroundColor: this.palette.panel, - focusedBackgroundColor: this.palette.panel, + backgroundColor: RGBA.defaultBackground(), + focusedBackgroundColor: RGBA.defaultBackground(), cursorColor: this.palette.accent, showCursor: true, keyBindings: [ @@ -648,12 +648,7 @@ export class NanobotTui { this.activeThemeMode = mode this.palette = mode === "light" ? LIGHT : DARK this.transcript.setTheme(transcriptTheme(this.palette)) - this.renderer.setBackgroundColor(this.palette.background) - this.shell.backgroundColor = this.palette.background - this.composerFrame.backgroundColor = this.palette.panel this.composerFrame.borderColor = this.palette.border - this.composer.backgroundColor = this.palette.panel - this.composer.focusedBackgroundColor = this.palette.panel this.composer.textColor = this.palette.text this.composer.focusedTextColor = this.palette.text this.composer.cursorColor = this.palette.accent