mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
fix(tui): inherit terminal background
This commit is contained in:
@@ -144,6 +144,16 @@ def main() -> int:
|
|||||||
raise AssertionError(f"TUI did not render committed input {glyph!r}")
|
raise AssertionError(f"TUI did not render committed input {glyph!r}")
|
||||||
if "Traceback" in text or "Task exception was never retrieved" in text:
|
if "Traceback" in text or "Task exception was never retrieved" in text:
|
||||||
raise AssertionError("TUI emitted an exception during shutdown")
|
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")
|
print("PTY smoke test passed: Unicode input, resize, and terminal restoration")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
+42
-13
@@ -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 () => {
|
test("rethemes the complete retained interface when the terminal appearance changes", async () => {
|
||||||
setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 80, height: 22, screenMode: "alternate-screen" })
|
||||||
const app = mount(setup)
|
const app = mount(setup)
|
||||||
@@ -225,9 +254,9 @@ describe("NanobotTui layout", () => {
|
|||||||
await setup.renderOnce()
|
await setup.renderOnce()
|
||||||
|
|
||||||
const internals = app as unknown as {
|
const internals = app as unknown as {
|
||||||
palette: { background: string; text: string; border: string }
|
palette: { referenceBackground: string; text: string; border: string }
|
||||||
shell: { backgroundColor: { toInts(): number[] } }
|
shell: { backgroundColor: { intent: string } }
|
||||||
composer: { backgroundColor: { toInts(): number[] }; textColor: { toInts(): number[] } }
|
composer: { backgroundColor: { intent: string }; textColor: { toInts(): number[] } }
|
||||||
transcript: {
|
transcript: {
|
||||||
frames: Set<{ borderColor: { toInts(): number[] } }>
|
frames: Set<{ borderColor: { toInts(): number[] } }>
|
||||||
markdown: Set<{ syntaxStyle: object }>
|
markdown: Set<{ syntaxStyle: object }>
|
||||||
@@ -240,12 +269,12 @@ describe("NanobotTui layout", () => {
|
|||||||
await setup.flush()
|
await setup.flush()
|
||||||
|
|
||||||
expect(internals.palette).toMatchObject({
|
expect(internals.palette).toMatchObject({
|
||||||
background: "#FAFAFA",
|
referenceBackground: "#FAFAFA",
|
||||||
text: "#18181B",
|
text: "#18181B",
|
||||||
border: "#D4D4D8",
|
border: "#D4D4D8",
|
||||||
})
|
})
|
||||||
expect(internals.shell.backgroundColor.toInts().slice(0, 3)).toEqual([250, 250, 250])
|
expect(internals.shell.backgroundColor.intent).toBe("default")
|
||||||
expect(internals.composer.backgroundColor.toInts().slice(0, 3)).toEqual([244, 244, 245])
|
expect(internals.composer.backgroundColor.intent).toBe("default")
|
||||||
expect(internals.composer.textColor.toInts().slice(0, 3)).toEqual([24, 24, 27])
|
expect(internals.composer.textColor.toInts().slice(0, 3)).toEqual([24, 24, 27])
|
||||||
expect([...internals.transcript.frames][0]?.borderColor.toInts().slice(0, 3)).toEqual([
|
expect([...internals.transcript.frames][0]?.borderColor.toInts().slice(0, 3)).toEqual([
|
||||||
212, 212, 216,
|
212, 212, 216,
|
||||||
@@ -261,7 +290,7 @@ describe("NanobotTui layout", () => {
|
|||||||
client(),
|
client(),
|
||||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
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" })
|
Object.defineProperty(setup.renderer, "themeMode", { configurable: true, value: "dark" })
|
||||||
|
|
||||||
await app.start()
|
await app.start()
|
||||||
@@ -269,7 +298,7 @@ describe("NanobotTui layout", () => {
|
|||||||
setup.renderer.emit(CliRenderEvents.THEME_MODE, "dark")
|
setup.renderer.emit(CliRenderEvents.THEME_MODE, "dark")
|
||||||
await setup.renderOnce()
|
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 () => {
|
test("waits for automatic terminal detection before connecting or painting", async () => {
|
||||||
@@ -296,7 +325,7 @@ describe("NanobotTui layout", () => {
|
|||||||
resolveMode("light")
|
resolveMode("light")
|
||||||
await starting
|
await starting
|
||||||
expect(connected).toBe(true)
|
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 () => {
|
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()
|
await app.start()
|
||||||
|
|
||||||
expect(connected).toBe(true)
|
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 () => {
|
test("keeps semantic colors legible in both terminal appearances", async () => {
|
||||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||||
const app = mount(setup)
|
const app = mount(setup)
|
||||||
const internals = app as unknown as {
|
const internals = app as unknown as {
|
||||||
palette: Record<string, string> & { background: string; panel: string; faint: string }
|
palette: Record<string, string> & { referenceBackground: string; faint: string }
|
||||||
}
|
}
|
||||||
const assertContrast = () => {
|
const assertContrast = () => {
|
||||||
for (const tone of ["text", "muted", "accent", "success", "error", "user", "warm", "cool"]) {
|
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()
|
assertContrast()
|
||||||
|
|||||||
+11
-16
@@ -41,8 +41,7 @@ interface ChatClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Palette {
|
interface Palette {
|
||||||
background: string
|
referenceBackground: string
|
||||||
panel: string
|
|
||||||
text: string
|
text: string
|
||||||
muted: string
|
muted: string
|
||||||
faint: string
|
faint: string
|
||||||
@@ -56,8 +55,7 @@ interface Palette {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DARK: Palette = {
|
const DARK: Palette = {
|
||||||
background: "#0E0F11",
|
referenceBackground: "#0E0F11",
|
||||||
panel: "#17181B",
|
|
||||||
text: "#ECEDEE",
|
text: "#ECEDEE",
|
||||||
muted: "#A1A1AA",
|
muted: "#A1A1AA",
|
||||||
faint: "#71717A",
|
faint: "#71717A",
|
||||||
@@ -71,8 +69,7 @@ const DARK: Palette = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LIGHT: Palette = {
|
const LIGHT: Palette = {
|
||||||
background: "#FAFAFA",
|
referenceBackground: "#FAFAFA",
|
||||||
panel: "#F4F4F5",
|
|
||||||
text: "#18181B",
|
text: "#18181B",
|
||||||
muted: "#6F6F78",
|
muted: "#6F6F78",
|
||||||
faint: "#8A8A94",
|
faint: "#8A8A94",
|
||||||
@@ -196,7 +193,10 @@ export class NanobotTui {
|
|||||||
onStatus: (status, detail) => this.handleStatus(status, detail),
|
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, {
|
this.shell = new BoxRenderable(renderer, {
|
||||||
id: "nanobot-tui-footer",
|
id: "nanobot-tui-footer",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -204,7 +204,7 @@ export class NanobotTui {
|
|||||||
paddingLeft: 1,
|
paddingLeft: 1,
|
||||||
paddingRight: 1,
|
paddingRight: 1,
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
backgroundColor: this.palette.background,
|
backgroundColor: RGBA.defaultBackground(),
|
||||||
})
|
})
|
||||||
this.title = new TextRenderable(renderer, {
|
this.title = new TextRenderable(renderer, {
|
||||||
id: "nanobot-tui-title",
|
id: "nanobot-tui-title",
|
||||||
@@ -223,7 +223,7 @@ export class NanobotTui {
|
|||||||
borderColor: this.palette.border,
|
borderColor: this.palette.border,
|
||||||
paddingLeft: 1,
|
paddingLeft: 1,
|
||||||
paddingRight: 1,
|
paddingRight: 1,
|
||||||
backgroundColor: this.palette.panel,
|
backgroundColor: RGBA.defaultBackground(),
|
||||||
})
|
})
|
||||||
this.composer = new TextareaRenderable(renderer, {
|
this.composer = new TextareaRenderable(renderer, {
|
||||||
id: "nanobot-tui-composer",
|
id: "nanobot-tui-composer",
|
||||||
@@ -235,8 +235,8 @@ export class NanobotTui {
|
|||||||
placeholderColor: this.palette.faint,
|
placeholderColor: this.palette.faint,
|
||||||
textColor: this.palette.text,
|
textColor: this.palette.text,
|
||||||
focusedTextColor: this.palette.text,
|
focusedTextColor: this.palette.text,
|
||||||
backgroundColor: this.palette.panel,
|
backgroundColor: RGBA.defaultBackground(),
|
||||||
focusedBackgroundColor: this.palette.panel,
|
focusedBackgroundColor: RGBA.defaultBackground(),
|
||||||
cursorColor: this.palette.accent,
|
cursorColor: this.palette.accent,
|
||||||
showCursor: true,
|
showCursor: true,
|
||||||
keyBindings: [
|
keyBindings: [
|
||||||
@@ -648,12 +648,7 @@ export class NanobotTui {
|
|||||||
this.activeThemeMode = mode
|
this.activeThemeMode = mode
|
||||||
this.palette = mode === "light" ? LIGHT : DARK
|
this.palette = mode === "light" ? LIGHT : DARK
|
||||||
this.transcript.setTheme(transcriptTheme(this.palette))
|
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.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.textColor = this.palette.text
|
||||||
this.composer.focusedTextColor = this.palette.text
|
this.composer.focusedTextColor = this.palette.text
|
||||||
this.composer.cursorColor = this.palette.accent
|
this.composer.cursorColor = this.palette.accent
|
||||||
|
|||||||
Reference in New Issue
Block a user