fix(tui): harden themes and platform coverage

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 347583d3f7
commit 35f2d086b0
11 changed files with 208 additions and 15 deletions
+6
View File
@@ -191,8 +191,14 @@ jobs:
os: ubuntu-latest os: ubuntu-latest
- name: Terminal UI (macOS) - name: Terminal UI (macOS)
os: macos-latest os: macos-latest
- name: Terminal UI (macOS Intel)
os: macos-15-intel
- name: Terminal UI (Windows) - name: Terminal UI (Windows)
os: windows-latest os: windows-latest
- name: Terminal UI (Linux arm64)
os: ubuntu-24.04-arm
- name: Terminal UI (Windows arm64)
os: windows-11-arm
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+4
View File
@@ -22,8 +22,12 @@ jobs:
target: darwin-x64 target: darwin-x64
- os: ubuntu-latest - os: ubuntu-latest
target: linux-x64 target: linux-x64
- os: ubuntu-24.04-arm
target: linux-arm64
- os: windows-latest - os: windows-latest
target: win32-x64 target: win32-x64
- os: windows-11-arm
target: win32-arm64
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+3
View File
@@ -92,6 +92,7 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r
| `nanobot agent --workspace <path>` | Override workspace | | `nanobot agent --workspace <path>` | Override workspace |
| `nanobot agent --config <path>` | Use a specific config file | | `nanobot agent --config <path>` | Use a specific config file |
| `nanobot agent --classic` | Use the classic Python prompt instead of the native terminal UI | | `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 --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 | | `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. 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. `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`. 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`.
+9
View File
@@ -67,6 +67,11 @@ def agent(
"--no-tui", "--no-tui",
help="Use the classic Python prompt instead of the native terminal UI", 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.""" """Interact with the agent directly."""
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@@ -75,6 +80,9 @@ def agent(
from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.providers.image_generation import image_gen_provider_configs
runtime_config = _load_runtime_config(config, workspace) 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 = ( native_tui = (
message is None message is None
and not classic and not classic
@@ -93,6 +101,7 @@ def agent(
config_path=get_config_path().resolve(strict=False), config_path=get_config_path().resolve(strict=False),
workspace_override=workspace, workspace_override=workspace,
session_id=session_id, session_id=session_id,
theme=theme,
) )
except TuiUnavailableError as exc: except TuiUnavailableError as exc:
console.print(f"[yellow]Native TUI unavailable: {exc}[/yellow]") console.print(f"[yellow]Native TUI unavailable: {exc}[/yellow]")
+2
View File
@@ -49,6 +49,7 @@ def launch_tui(
config_path: Path, config_path: Path,
workspace_override: str | None, workspace_override: str | None,
session_id: str, session_id: str,
theme: str,
) -> int: ) -> int:
"""Run the native TUI, owning a local gateway only when one is not running.""" """Run the native TUI, owning a local gateway only when one is not running."""
command = _resolve_tui_command() command = _resolve_tui_command()
@@ -74,6 +75,7 @@ def launch_tui(
"NANOBOT_TUI_ACCESS": ( "NANOBOT_TUI_ACCESS": (
"workspace access" if config.tools.restrict_to_workspace else "full access" "workspace access" if config.tools.restrict_to_workspace else "full access"
), ),
"NANOBOT_TUI_THEME": theme,
} }
) )
chat_id = _websocket_chat_id(session_id) chat_id = _websocket_chat_id(session_id)
+9
View File
@@ -1565,6 +1565,15 @@ def test_agent_help_shows_workspace_and_config_options():
assert "-w" in stripped_output assert "-w" in stripped_output
assert "--config" in stripped_output assert "--config" in stripped_output
assert "-c" 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): def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_runtime):
+2
View File
@@ -73,6 +73,7 @@ def test_interactive_agent_uses_native_tui(
markdown=True, markdown=True,
logs=False, logs=False,
classic=False, classic=False,
theme="light",
) )
assert launched["args"] == (config,) assert launched["args"] == (config,)
@@ -80,6 +81,7 @@ def test_interactive_agent_uses_native_tui(
"config_path": config_path, "config_path": config_path,
"workspace_override": None, "workspace_override": None,
"session_id": "websocket:terminal-chat", "session_id": "websocket:terminal-chat",
"theme": "light",
} }
+116 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { TextareaRenderable } from "@opentui/core" import { CliRenderEvents, TextareaRenderable } from "@opentui/core"
import { import {
MockTreeSitterClient, MockTreeSitterClient,
createTestRenderer, createTestRenderer,
@@ -16,12 +16,25 @@ const options: AppOptions = {
workspace: "/tmp/nanobot-workspace", workspace: "/tmp/nanobot-workspace",
version: "test", version: "test",
access: "workspace access", access: "workspace access",
theme: "auto",
} }
function occurrences(frame: string, value: string): number { function occurrences(frame: string, value: string): number {
return frame.split(value).length - 1 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> { async function waitUntil(predicate: () => boolean, timeout = 1_000): Promise<void> {
const deadline = Date.now() + timeout const deadline = Date.now() + timeout
while (!predicate() && Date.now() < deadline) await Bun.sleep(5) 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 () => { test("replaces streamed drafts with canonical stream-end text", async () => {
setup = await createRenderer({ width: 80, height: 20, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 80, height: 20, screenMode: "alternate-screen" })
const app = mount(setup) const app = mount(setup)
+43 -13
View File
@@ -9,6 +9,7 @@ import {
getTreeSitterClient, getTreeSitterClient,
type CliRenderer, type CliRenderer,
type KeyEvent, type KeyEvent,
type ThemeMode,
type TreeSitterClient, type TreeSitterClient,
} from "@opentui/core" } from "@opentui/core"
@@ -29,6 +30,7 @@ interface AppOptions {
workspace: string workspace: string
version: string version: string
access: string access: string
theme: "auto" | ThemeMode
} }
interface ChatClient { interface ChatClient {
@@ -49,6 +51,8 @@ interface Palette {
success: string success: string
error: string error: string
user: string user: string
warm: string
cool: string
} }
const DARK: Palette = { const DARK: Palette = {
@@ -62,19 +66,23 @@ const DARK: Palette = {
success: "#5CC489", success: "#5CC489",
error: "#F87171", error: "#F87171",
user: "#60A5FA", user: "#60A5FA",
warm: "#C26A25",
cool: "#1795A2",
} }
const LIGHT: Palette = { const LIGHT: Palette = {
background: "#FAFAFA", background: "#FAFAFA",
panel: "#F4F4F5", panel: "#F4F4F5",
text: "#18181B", text: "#18181B",
muted: "#71717A", muted: "#6F6F78",
faint: "#A1A1AA", faint: "#8A8A94",
border: "#D4D4D8", border: "#D4D4D8",
accent: "#6D5BD0", accent: "#5B4BC4",
success: "#218358", success: "#166534",
error: "#DC2626", error: "#B91C1C",
user: "#2563EB", user: "#1D4ED8",
warm: "#C2410C",
cool: "#0F766E",
} }
function syntaxStyle(palette: Palette): SyntaxStyle { function syntaxStyle(palette: Palette): SyntaxStyle {
@@ -88,8 +96,8 @@ function syntaxStyle(palette: Palette): SyntaxStyle {
string: color(palette.success), string: color(palette.success),
comment: { ...color(palette.muted), italic: true }, comment: { ...color(palette.muted), italic: true },
number: color(palette.user), number: color(palette.user),
function: color("#C26A25"), function: color(palette.warm),
type: color("#168A96"), type: color(palette.cool),
variable: color(palette.text), variable: color(palette.text),
property: color(palette.user), property: color(palette.user),
"markup.heading": { ...color(palette.accent), bold: true }, "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": { ...color(palette.user), underline: true },
"markup.link.label": { ...color(palette.user), underline: true }, "markup.link.label": { ...color(palette.user), underline: true },
"markup.link.url": { ...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), conceal: color(palette.faint),
}) })
} }
@@ -150,6 +158,7 @@ export class NanobotTui {
private readonly status: TextRenderable private readonly status: TextRenderable
private readonly meta: TextRenderable private readonly meta: TextRenderable
private palette: Palette private palette: Palette
private activeThemeMode: ThemeMode
private activeTurn = false private activeTurn = false
private activeLabel = "Thinking" private activeLabel = "Thinking"
private activeStartedAt = 0 private activeStartedAt = 0
@@ -177,7 +186,8 @@ export class NanobotTui {
treeSitterClient = getTreeSitterClient(), treeSitterClient = getTreeSitterClient(),
) { ) {
this.renderer = renderer 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.transcript = new Transcript(renderer, transcriptTheme(this.palette), treeSitterClient)
this.client = client || new NanobotClient({ this.client = client || new NanobotClient({
url: options.wsUrl, url: options.wsUrl,
@@ -300,7 +310,16 @@ export class NanobotTui {
return new NanobotTui(renderer, options, client, treeSitterClient) 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.client.connect()
this.renderer.start() this.renderer.start()
} }
@@ -615,8 +634,19 @@ export class NanobotTui {
return true return true
} }
private handleTheme = (): void => { private handleTheme = (mode: ThemeMode): void => {
this.palette = this.renderer.themeMode === "light" ? LIGHT : DARK 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.transcript.setTheme(transcriptTheme(this.palette))
this.renderer.setBackgroundColor(this.palette.background) this.renderer.setBackgroundColor(this.palette.background)
this.shell.backgroundColor = this.palette.background this.shell.backgroundColor = this.palette.background
+8 -1
View File
@@ -6,6 +6,12 @@ function required(name: string): string {
return value 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 = { const options: AppOptions = {
wsUrl: required("NANOBOT_TUI_WS_URL"), wsUrl: required("NANOBOT_TUI_WS_URL"),
apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "", apiUrl: process.env.NANOBOT_TUI_API_URL?.trim() || "",
@@ -15,6 +21,7 @@ const options: AppOptions = {
workspace: process.env.NANOBOT_TUI_WORKSPACE?.trim() || "", workspace: process.env.NANOBOT_TUI_WORKSPACE?.trim() || "",
version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev", version: process.env.NANOBOT_TUI_VERSION?.trim() || "dev",
access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access", access: process.env.NANOBOT_TUI_ACCESS?.trim() || "workspace access",
theme: themePreference(),
} }
let app: NanobotTui | undefined let app: NanobotTui | undefined
@@ -42,4 +49,4 @@ process.once("unhandledRejection", (error) => {
app = await NanobotTui.create(options) app = await NanobotTui.create(options)
if (shuttingDown) app.stop() if (shuttingDown) app.stop()
else app.start() else await app.start()
+6
View File
@@ -81,10 +81,15 @@ export class Transcript {
} }
setTheme(theme: TranscriptTheme): void { setTheme(theme: TranscriptTheme): void {
const previousSyntax = this.theme.syntax
this.theme = theme this.theme = theme
for (const { renderable, tone } of this.styledText) renderable.fg = theme[tone] for (const { renderable, tone } of this.styledText) renderable.fg = theme[tone]
for (const renderable of this.markdown) renderable.syntaxStyle = theme.syntax for (const renderable of this.markdown) renderable.syntaxStyle = theme.syntax
for (const frame of this.frames) frame.borderColor = theme.border 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 { header(options: TranscriptHeader): void {
@@ -225,6 +230,7 @@ export class Transcript {
destroy(): void { destroy(): void {
this.live = null this.live = null
this.activity = null this.activity = null
this.theme.syntax.destroy()
} }
private id(prefix: string): string { private id(prefix: string): string {