mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
fix(tui): harden themes and platform coverage
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -92,6 +92,7 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r
|
||||
| `nanobot agent --workspace <path>` | Override workspace |
|
||||
| `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 --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`.
|
||||
|
||||
@@ -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]")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+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