From 35f2d086b0c8a205974f4f071bb16237fd6b4d46 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:44:49 +0900 Subject: [PATCH] fix(tui): harden themes and platform coverage --- .github/workflows/ci.yml | 6 ++ .github/workflows/tui-release.yml | 4 + docs/cli-reference.md | 3 + nanobot/cli/agent.py | 9 +++ nanobot/cli/tui_launcher.py | 2 + tests/cli/test_commands.py | 9 +++ tests/cli/test_tui_launcher.py | 2 + tui/src/app.test.ts | 117 +++++++++++++++++++++++++++++- tui/src/app.ts | 56 ++++++++++---- tui/src/index.ts | 9 ++- tui/src/transcript.ts | 6 ++ 11 files changed, 208 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b10bbb5b..e79c77009 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,8 +191,14 @@ jobs: os: ubuntu-latest - name: Terminal UI (macOS) os: macos-latest + - name: Terminal UI (macOS Intel) + os: macos-15-intel - name: Terminal UI (Windows) os: windows-latest + - name: Terminal UI (Linux arm64) + os: ubuntu-24.04-arm + - name: Terminal UI (Windows arm64) + os: windows-11-arm steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/tui-release.yml b/.github/workflows/tui-release.yml index 7445737a5..72b0d5a15 100644 --- a/.github/workflows/tui-release.yml +++ b/.github/workflows/tui-release.yml @@ -22,8 +22,12 @@ jobs: target: darwin-x64 - os: ubuntu-latest target: linux-x64 + - os: ubuntu-24.04-arm + target: linux-arm64 - os: windows-latest target: win32-x64 + - os: windows-11-arm + target: win32-arm64 steps: - uses: actions/checkout@v4 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 92771bc99..9d1e34901 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -92,6 +92,7 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r | `nanobot agent --workspace ` | Override workspace | | `nanobot agent --config ` | Use a specific config file | | `nanobot agent --classic` | Use the classic Python prompt instead of the native terminal UI | +| `nanobot agent --theme auto\|dark\|light` | Auto-detect the terminal appearance or force a TUI palette | | `nanobot agent --no-markdown` | Use the classic prompt and print plain text instead of Markdown | | `nanobot agent --logs` | Use the classic prompt and show runtime logs while chatting | @@ -115,6 +116,8 @@ workspace file. Back up both the config directory and workspace before changing Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, the command starts one for the lifetime of the terminal UI and stops it on exit. +The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably. + `Enter` sends the current message. Press `Alt+Enter` to add a newline and use `Up`/`Down` at the composer edge to recall recent prompts. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen. Packaged releases fetch a version-matched, checksummed terminal binary for macOS, Linux, or Windows on first use and cache it under the nanobot data directory. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`. diff --git a/nanobot/cli/agent.py b/nanobot/cli/agent.py index d86c39f77..53825ca00 100644 --- a/nanobot/cli/agent.py +++ b/nanobot/cli/agent.py @@ -67,6 +67,11 @@ def agent( "--no-tui", help="Use the classic Python prompt instead of the native terminal UI", ), + theme: str = typer.Option( + "auto", + "--theme", + help="Terminal UI appearance: auto, dark, or light", + ), ): """Interact with the agent directly.""" from nanobot.bus.queue import MessageBus @@ -75,6 +80,9 @@ def agent( from nanobot.providers.image_generation import image_gen_provider_configs runtime_config = _load_runtime_config(config, workspace) + theme = theme.strip().lower() + if theme not in {"auto", "dark", "light"}: + raise typer.BadParameter("must be auto, dark, or light", param_hint="--theme") native_tui = ( message is None and not classic @@ -93,6 +101,7 @@ def agent( config_path=get_config_path().resolve(strict=False), workspace_override=workspace, session_id=session_id, + theme=theme, ) except TuiUnavailableError as exc: console.print(f"[yellow]Native TUI unavailable: {exc}[/yellow]") diff --git a/nanobot/cli/tui_launcher.py b/nanobot/cli/tui_launcher.py index 1c28ca5db..1a0edd081 100644 --- a/nanobot/cli/tui_launcher.py +++ b/nanobot/cli/tui_launcher.py @@ -49,6 +49,7 @@ def launch_tui( config_path: Path, workspace_override: str | None, session_id: str, + theme: str, ) -> int: """Run the native TUI, owning a local gateway only when one is not running.""" command = _resolve_tui_command() @@ -74,6 +75,7 @@ def launch_tui( "NANOBOT_TUI_ACCESS": ( "workspace access" if config.tools.restrict_to_workspace else "full access" ), + "NANOBOT_TUI_THEME": theme, } ) chat_id = _websocket_chat_id(session_id) diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index b55bb2fd6..9a779a4f3 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -1565,6 +1565,15 @@ def test_agent_help_shows_workspace_and_config_options(): assert "-w" in stripped_output assert "--config" in stripped_output assert "-c" in stripped_output + assert "--theme" in stripped_output + + +def test_agent_rejects_unknown_tui_theme(mock_agent_runtime): + result = runner.invoke(app, ["agent", "-m", "hello", "--theme", "sepia"]) + + assert result.exit_code != 0 + assert "must be auto, dark, or light" in result.output + mock_agent_runtime["from_config"].assert_not_called() def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_runtime): diff --git a/tests/cli/test_tui_launcher.py b/tests/cli/test_tui_launcher.py index e60221c85..c4d64db29 100644 --- a/tests/cli/test_tui_launcher.py +++ b/tests/cli/test_tui_launcher.py @@ -73,6 +73,7 @@ def test_interactive_agent_uses_native_tui( markdown=True, logs=False, classic=False, + theme="light", ) assert launched["args"] == (config,) @@ -80,6 +81,7 @@ def test_interactive_agent_uses_native_tui( "config_path": config_path, "workspace_override": None, "session_id": "websocket:terminal-chat", + "theme": "light", } diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 17ee30287..67c6c1712 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -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 { 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 & { 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) diff --git a/tui/src/app.ts b/tui/src/app.ts index 28f492f56..cb5632050 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -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 { + // 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 diff --git a/tui/src/index.ts b/tui/src/index.ts index 851e21f41..9d42fc869 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -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() diff --git a/tui/src/transcript.ts b/tui/src/transcript.ts index 91be3237c..d01b607d8 100644 --- a/tui/src/transcript.ts +++ b/tui/src/transcript.ts @@ -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 {