mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 00:31:51 +03:00
fix(tui): harden themes and platform coverage
This commit is contained in:
+116
-1
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { TextareaRenderable } from "@opentui/core"
|
||||
import { CliRenderEvents, TextareaRenderable } from "@opentui/core"
|
||||
import {
|
||||
MockTreeSitterClient,
|
||||
createTestRenderer,
|
||||
@@ -16,12 +16,25 @@ const options: AppOptions = {
|
||||
workspace: "/tmp/nanobot-workspace",
|
||||
version: "test",
|
||||
access: "workspace access",
|
||||
theme: "auto",
|
||||
}
|
||||
|
||||
function occurrences(frame: string, value: string): number {
|
||||
return frame.split(value).length - 1
|
||||
}
|
||||
|
||||
function contrastRatio(foreground: string, background: string): number {
|
||||
const luminance = (color: string) => {
|
||||
const channel = (offset: number) => {
|
||||
const value = Number.parseInt(color.slice(offset, offset + 2), 16) / 255
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5)
|
||||
}
|
||||
const [lighter, darker] = [luminance(foreground), luminance(background)].sort((a, b) => b - a)
|
||||
return ((lighter ?? 0) + 0.05) / ((darker ?? 0) + 0.05)
|
||||
}
|
||||
|
||||
async function waitUntil(predicate: () => boolean, timeout = 1_000): Promise<void> {
|
||||
const deadline = Date.now() + timeout
|
||||
while (!predicate() && Date.now() < deadline) await Bun.sleep(5)
|
||||
@@ -179,6 +192,108 @@ describe("NanobotTui layout", () => {
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
app.accept({ event: "attached", chat_id: "chat" })
|
||||
app.accept({ event: "delta", chat_id: "chat", text: "# Existing answer" })
|
||||
app.accept({ event: "stream_end", chat_id: "chat" })
|
||||
app.accept({ event: "message", chat_id: "chat", text: "tool", kind: "tool_hint" })
|
||||
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[] } }
|
||||
transcript: {
|
||||
frames: Set<{ borderColor: { toInts(): number[] } }>
|
||||
markdown: Set<{ syntaxStyle: object }>
|
||||
}
|
||||
}
|
||||
const markdown = [...internals.transcript.markdown][0]
|
||||
const darkSyntax = markdown?.syntaxStyle
|
||||
|
||||
setup.renderer.emit(CliRenderEvents.THEME_MODE, "light")
|
||||
await setup.flush()
|
||||
|
||||
expect(internals.palette).toMatchObject({
|
||||
background: "#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.composer.textColor.toInts().slice(0, 3)).toEqual([24, 24, 27])
|
||||
expect([...internals.transcript.frames][0]?.borderColor.toInts().slice(0, 3)).toEqual([
|
||||
212, 212, 216,
|
||||
])
|
||||
expect(markdown?.syntaxStyle).not.toBe(darkSyntax)
|
||||
})
|
||||
|
||||
test("keeps an explicit theme stable when the terminal reports another mode", async () => {
|
||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||
const app = NanobotTui.mount(
|
||||
setup.renderer,
|
||||
{ ...options, theme: "light" },
|
||||
client(),
|
||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||
)
|
||||
const internals = app as unknown as { palette: { background: string } }
|
||||
Object.defineProperty(setup.renderer, "themeMode", { configurable: true, value: "dark" })
|
||||
|
||||
await app.start()
|
||||
|
||||
setup.renderer.emit(CliRenderEvents.THEME_MODE, "dark")
|
||||
await setup.renderOnce()
|
||||
|
||||
expect(internals.palette.background).toBe("#FAFAFA")
|
||||
})
|
||||
|
||||
test("waits for automatic terminal detection before connecting or painting", async () => {
|
||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||
let connected = false
|
||||
let resolveMode: (mode: "light") => void = () => undefined
|
||||
setup.renderer.waitForThemeMode = () => new Promise((resolve) => {
|
||||
resolveMode = resolve
|
||||
})
|
||||
Object.defineProperty(setup.renderer, "themeMode", { configurable: true, value: "light" })
|
||||
const transport = client()
|
||||
transport.connect = () => { connected = true }
|
||||
const app = NanobotTui.mount(
|
||||
setup.renderer,
|
||||
options,
|
||||
transport,
|
||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||
)
|
||||
|
||||
const starting = app.start()
|
||||
await Bun.sleep(1)
|
||||
expect(connected).toBe(false)
|
||||
|
||||
resolveMode("light")
|
||||
await starting
|
||||
expect(connected).toBe(true)
|
||||
expect((app as unknown as { palette: { background: string } }).palette.background).toBe("#FAFAFA")
|
||||
})
|
||||
|
||||
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<string, string> & { background: string; panel: 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.faint, internals.palette.panel)).toBeGreaterThanOrEqual(3)
|
||||
}
|
||||
|
||||
assertContrast()
|
||||
setup.renderer.emit(CliRenderEvents.THEME_MODE, "light")
|
||||
assertContrast()
|
||||
})
|
||||
|
||||
test("replaces streamed drafts with canonical stream-end text", async () => {
|
||||
setup = await createRenderer({ width: 80, height: 20, screenMode: "alternate-screen" })
|
||||
const app = mount(setup)
|
||||
|
||||
+43
-13
@@ -9,6 +9,7 @@ import {
|
||||
getTreeSitterClient,
|
||||
type CliRenderer,
|
||||
type KeyEvent,
|
||||
type ThemeMode,
|
||||
type TreeSitterClient,
|
||||
} from "@opentui/core"
|
||||
|
||||
@@ -29,6 +30,7 @@ interface AppOptions {
|
||||
workspace: string
|
||||
version: string
|
||||
access: string
|
||||
theme: "auto" | ThemeMode
|
||||
}
|
||||
|
||||
interface ChatClient {
|
||||
@@ -49,6 +51,8 @@ interface Palette {
|
||||
success: string
|
||||
error: string
|
||||
user: string
|
||||
warm: string
|
||||
cool: string
|
||||
}
|
||||
|
||||
const DARK: Palette = {
|
||||
@@ -62,19 +66,23 @@ const DARK: Palette = {
|
||||
success: "#5CC489",
|
||||
error: "#F87171",
|
||||
user: "#60A5FA",
|
||||
warm: "#C26A25",
|
||||
cool: "#1795A2",
|
||||
}
|
||||
|
||||
const LIGHT: Palette = {
|
||||
background: "#FAFAFA",
|
||||
panel: "#F4F4F5",
|
||||
text: "#18181B",
|
||||
muted: "#71717A",
|
||||
faint: "#A1A1AA",
|
||||
muted: "#6F6F78",
|
||||
faint: "#8A8A94",
|
||||
border: "#D4D4D8",
|
||||
accent: "#6D5BD0",
|
||||
success: "#218358",
|
||||
error: "#DC2626",
|
||||
user: "#2563EB",
|
||||
accent: "#5B4BC4",
|
||||
success: "#166534",
|
||||
error: "#B91C1C",
|
||||
user: "#1D4ED8",
|
||||
warm: "#C2410C",
|
||||
cool: "#0F766E",
|
||||
}
|
||||
|
||||
function syntaxStyle(palette: Palette): SyntaxStyle {
|
||||
@@ -88,8 +96,8 @@ function syntaxStyle(palette: Palette): SyntaxStyle {
|
||||
string: color(palette.success),
|
||||
comment: { ...color(palette.muted), italic: true },
|
||||
number: color(palette.user),
|
||||
function: color("#C26A25"),
|
||||
type: color("#168A96"),
|
||||
function: color(palette.warm),
|
||||
type: color(palette.cool),
|
||||
variable: color(palette.text),
|
||||
property: color(palette.user),
|
||||
"markup.heading": { ...color(palette.accent), bold: true },
|
||||
@@ -98,7 +106,7 @@ function syntaxStyle(palette: Palette): SyntaxStyle {
|
||||
"markup.link": { ...color(palette.user), underline: true },
|
||||
"markup.link.label": { ...color(palette.user), underline: true },
|
||||
"markup.link.url": { ...color(palette.user), underline: true },
|
||||
"markup.raw": color("#C26A25"),
|
||||
"markup.raw": color(palette.warm),
|
||||
conceal: color(palette.faint),
|
||||
})
|
||||
}
|
||||
@@ -150,6 +158,7 @@ export class NanobotTui {
|
||||
private readonly status: TextRenderable
|
||||
private readonly meta: TextRenderable
|
||||
private palette: Palette
|
||||
private activeThemeMode: ThemeMode
|
||||
private activeTurn = false
|
||||
private activeLabel = "Thinking"
|
||||
private activeStartedAt = 0
|
||||
@@ -177,7 +186,8 @@ export class NanobotTui {
|
||||
treeSitterClient = getTreeSitterClient(),
|
||||
) {
|
||||
this.renderer = renderer
|
||||
this.palette = renderer.themeMode === "light" ? LIGHT : DARK
|
||||
this.activeThemeMode = this.resolveThemeMode(renderer.themeMode)
|
||||
this.palette = this.activeThemeMode === "light" ? LIGHT : DARK
|
||||
this.transcript = new Transcript(renderer, transcriptTheme(this.palette), treeSitterClient)
|
||||
this.client = client || new NanobotClient({
|
||||
url: options.wsUrl,
|
||||
@@ -300,7 +310,16 @@ export class NanobotTui {
|
||||
return new NanobotTui(renderer, options, client, treeSitterClient)
|
||||
}
|
||||
|
||||
start(): void {
|
||||
async start(): Promise<void> {
|
||||
// OpenTUI learns the real terminal background through OSC 10/11. Wait for
|
||||
// that bounded probe before first paint, as OpenCode does, so a light
|
||||
// terminal does not briefly render the dark palette. The app already owns
|
||||
// the renderer here, so a signal during the probe can still restore it.
|
||||
if (this.options.theme === "auto") await this.renderer.waitForThemeMode(1_000)
|
||||
if (this.quitting) return
|
||||
if (this.options.theme === "auto" && this.renderer.themeMode) {
|
||||
this.applyTheme(this.renderer.themeMode)
|
||||
}
|
||||
this.client.connect()
|
||||
this.renderer.start()
|
||||
}
|
||||
@@ -615,8 +634,19 @@ export class NanobotTui {
|
||||
return true
|
||||
}
|
||||
|
||||
private handleTheme = (): void => {
|
||||
this.palette = this.renderer.themeMode === "light" ? LIGHT : DARK
|
||||
private handleTheme = (mode: ThemeMode): void => {
|
||||
if (this.options.theme !== "auto") return
|
||||
this.applyTheme(mode)
|
||||
}
|
||||
|
||||
private resolveThemeMode(detected: ThemeMode | null): ThemeMode {
|
||||
return this.options.theme === "auto" ? detected ?? "dark" : this.options.theme
|
||||
}
|
||||
|
||||
private applyTheme(mode: ThemeMode): void {
|
||||
if (this.activeThemeMode === mode) return
|
||||
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
|
||||
|
||||
+8
-1
@@ -6,6 +6,12 @@ function required(name: string): string {
|
||||
return value
|
||||
}
|
||||
|
||||
function themePreference(): AppOptions["theme"] {
|
||||
const value = process.env.NANOBOT_TUI_THEME?.trim() || "auto"
|
||||
if (value === "auto" || value === "dark" || value === "light") return value
|
||||
throw new Error("NANOBOT_TUI_THEME must be auto, dark, or light")
|
||||
}
|
||||
|
||||
const options: AppOptions = {
|
||||
wsUrl: required("NANOBOT_TUI_WS_URL"),
|
||||
apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "",
|
||||
@@ -15,6 +21,7 @@ const options: AppOptions = {
|
||||
workspace: process.env.NANOBOT_TUI_WORKSPACE?.trim() || "",
|
||||
version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev",
|
||||
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access",
|
||||
theme: themePreference(),
|
||||
}
|
||||
|
||||
let app: NanobotTui | undefined
|
||||
@@ -42,4 +49,4 @@ process.once("unhandledRejection", (error) => {
|
||||
|
||||
app = await NanobotTui.create(options)
|
||||
if (shuttingDown) app.stop()
|
||||
else app.start()
|
||||
else await app.start()
|
||||
|
||||
@@ -81,10 +81,15 @@ export class Transcript {
|
||||
}
|
||||
|
||||
setTheme(theme: TranscriptTheme): void {
|
||||
const previousSyntax = this.theme.syntax
|
||||
this.theme = theme
|
||||
for (const { renderable, tone } of this.styledText) renderable.fg = theme[tone]
|
||||
for (const renderable of this.markdown) renderable.syntaxStyle = theme.syntax
|
||||
for (const frame of this.frames) frame.borderColor = theme.border
|
||||
// Markdown may still be rendering this frame. Release the prior native
|
||||
// style only after the renderer reaches idle, matching OpenCode's retained
|
||||
// theme lifecycle and avoiding both leaks and use-after-free transitions.
|
||||
void this.renderer.idle().catch(() => {}).finally(() => previousSyntax.destroy())
|
||||
}
|
||||
|
||||
header(options: TranscriptHeader): void {
|
||||
@@ -225,6 +230,7 @@ export class Transcript {
|
||||
destroy(): void {
|
||||
this.live = null
|
||||
this.activity = null
|
||||
this.theme.syntax.destroy()
|
||||
}
|
||||
|
||||
private id(prefix: string): string {
|
||||
|
||||
Reference in New Issue
Block a user