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 1b10bbb5..e79c7700 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 7445737a..72b0d5a1 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 92771bc9..9d1e3490 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 d86c39f7..53825ca0 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 1c28ca5d..1a0edd08 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 b55bb2fd..9a779a4f 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 e60221c8..c4d64db2 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 17ee3028..67c6c171 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 28f492f5..cb563205 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 851e21f4..9d42fc86 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 91be3237..d01b607d 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 {