Compare commits

...
Author SHA1 Message Date
Xubin Ren 61b2f169a8 fix(tui): inherit markdown foreground colors
Pass the active transcript foreground into retained Markdown renderables so unhighlighted fenced code remains visible on light terminal backgrounds. Update existing renderables during theme changes and cover the history hydration path that exposed the regression.
2026-09-03 17:02:40 +08:00
Xubin Ren 54e5c63b7e fix(pairing): avoid duplicate pending requests 2026-09-03 15:45:57 +08:00
Xubin Ren 41477c2510 fix(tui): preserve streamed code on completion 2026-09-03 15:45:57 +08:00
Xubin Ren 0573cefa3b fix(webui): center project session labels 2026-09-03 15:45:57 +08:00
10 changed files with 159 additions and 19 deletions
+10
View File
@@ -49,6 +49,16 @@ def _isolate_sessions_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> I
yield
@pytest.fixture(autouse=True)
def _isolate_pairing_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep channel pairing tests out of the user's active pairing store."""
pairing_path = tmp_path / "pairing.json"
monkeypatch.setattr(
"nanobot.pairing.store._store_path",
lambda: pairing_path,
)
@pytest.fixture(scope="session", autouse=True)
def _use_windows_system_ca_for_default_http_clients() -> Iterator[None]:
"""Avoid reparsing certifi's CA bundle for every offline HTTP client.
+7 -2
View File
@@ -115,19 +115,24 @@ def generate_code(
sender_id: str,
ttl: int = _TTL_DEFAULT_S,
) -> str:
"""Create a new pairing code for *sender_id* on *channel*.
"""Return an active pairing code for *sender_id* on *channel*.
Returns the code (e.g. ``"ABCD-EFGH"``).
"""
with _LOCK:
data = _load()
_gc_pending(data)
sender = str(sender_id)
for code, info in data.get("pending", {}).items():
if info["channel"] == channel and str(info["sender_id"]) == sender:
return code
raw = "".join(secrets.choice(_ALPHABET) for _ in range(_CODE_LENGTH))
code = f"{raw[:4]}-{raw[4:]}"
data.setdefault("pending", {})[code] = {
"channel": channel,
"sender_id": str(sender_id),
"sender_id": sender,
"created_at": time.time(),
"expires_at": time.time() + ttl,
}
+13
View File
@@ -32,6 +32,19 @@ class TestGenerateCode:
codes = {store.generate_code("telegram", str(i)) for i in range(20)}
assert len(codes) == 20
def test_reuses_active_code_for_same_sender(self) -> None:
first = store.generate_code("telegram", "123")
assert store.generate_code("telegram", "123") == first
assert len(store.list_pending()) == 1
def test_scopes_reused_codes_to_channel(self) -> None:
telegram = store.generate_code("telegram", "123")
discord = store.generate_code("discord", "123")
assert telegram != discord
assert len(store.list_pending()) == 2
def test_ttl_expiration(self, monkeypatch) -> None:
clock = {"now": 1_000.0}
monkeypatch.setattr(store.time, "time", lambda: clock["now"])
+10 -10
View File
@@ -5,7 +5,7 @@
"": {
"name": "@nanobot/tui",
"dependencies": {
"@opentui/core": "0.5.3",
"@opentui/core": "0.5.10",
},
"devDependencies": {
"@types/bun": "^1.3.13",
@@ -14,23 +14,23 @@
},
},
"packages": {
"@opentui/core": ["@opentui/core@0.5.3", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.3", "@opentui/core-darwin-x64": "0.5.3", "@opentui/core-linux-arm64": "0.5.3", "@opentui/core-linux-arm64-musl": "0.5.3", "@opentui/core-linux-x64": "0.5.3", "@opentui/core-linux-x64-musl": "0.5.3", "@opentui/core-win32-arm64": "0.5.3", "@opentui/core-win32-x64": "0.5.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-K8EQu44cx0rhnn3v3baCQW18Bpci3GltZayOwVpGGsbiAGL1WUYqwQjuaWsmS0c4dCa9rQ5xCEoHB1C4936nDg=="],
"@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1pmUas/chTVFGeiN19kaOx+5Xbte/DLhcgKyACwWO0M3+xE3z1v/6QGSyX6CoP5HBpmDroiX+JHv1ic/JlGd/g=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-BkVIiPQ1TOf5/FfmIpf7DQU5rT/FO6ASW5R/o/wonI5Pdul7XiDCu86gzGyk1x5k9Sbh6GLeq1fe8/tPmI7IaA=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
+1 -1
View File
@@ -10,7 +10,7 @@
"test": "bun test"
},
"dependencies": {
"@opentui/core": "0.5.3"
"@opentui/core": "0.5.10"
},
"devDependencies": {
"@types/bun": "^1.3.13",
+88 -1
View File
@@ -9,6 +9,7 @@ import {
} from "@opentui/core"
import {
MockTreeSitterClient,
TestRecorder,
createTestRenderer,
type TestRendererSetup,
} from "@opentui/core/testing"
@@ -491,6 +492,17 @@ describe("NanobotTui layout", () => {
expect(ui.composer.plainText).toContain("replacement")
expect(ui.composer.plainText).not.toContain("Image #1")
ui.composer.setText("")
setup.mockInput.pressKey("v", { ctrl: true })
await waitUntil(() => ui.composer.plainText === "[Image #1] ")
ui.composer.cursorOffset = 10
setup.mockInput.pressArrow("left", { shift: true })
await waitUntil(() => ui.composer.cursorOffset === 0)
await setup.mockInput.typeText("replacement")
await waitUntil(() => ui.draft.imageCount === 0)
expect(ui.composer.plainText).toContain("replacement")
expect(ui.composer.plainText).not.toContain("Image #1")
ui.composer.setText("")
setup.mockInput.pressKey("v", { ctrl: true })
await waitUntil(() => ui.composer.plainText === "[Image #1] ")
@@ -2052,6 +2064,80 @@ describe("NanobotTui layout", () => {
}
})
test("keeps streamed fenced code visible while completing the response", async () => {
setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" })
const app = mount(setup)
const response = [
"Commit types:",
"",
"```text",
"feat:",
"fix:",
"perf:",
"docs:",
"test:",
"refactor:",
"chore:",
"```",
"",
"Include the reason in the body.",
].join("\n")
app.accept({ event: "attached", chat_id: "chat" })
app.accept({ event: "delta", chat_id: "chat", text: response })
await setup.flush()
expect(setup.captureCharFrame()).toContain("feat:")
const recorder = new TestRecorder(setup.renderer)
recorder.rec()
app.accept({ event: "stream_end", chat_id: "chat" })
app.accept({ event: "turn_end", chat_id: "chat" })
await setup.flush()
recorder.stop()
expect(recorder.recordedFrames.length).toBeGreaterThan(0)
expect(recorder.recordedFrames.every(({ frame }) => frame.includes("feat:"))).toBeTrue()
expect(recorder.recordedFrames.every(({ frame }) => (
frame.includes("Include the reason in the body.")
))).toBeTrue()
})
test("renders fenced plain text from light-theme history", async () => {
setup = await createRenderer({ width: 100, height: 30, screenMode: "alternate-screen" })
const app = NanobotTui.mount(
setup.renderer,
{ ...options, theme: "light" },
client(),
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
const response = [
"Commit types:",
"",
"```text",
"feat:",
"fix:",
"perf:",
"docs:",
"test:",
"refactor:",
"chore:",
"```",
"",
"Include the reason in the body.",
].join("\n")
const transcript = (app as unknown as { transcript: Transcript }).transcript
transcript.history([{ role: "assistant", content: response }])
await setup.flush()
const code = setup.captureSpans().lines
.flatMap((line) => line.spans)
.find((span) => span.text.includes("feat:"))
expect(setup.captureCharFrame()).toContain("feat:")
expect(code?.fg.toInts().slice(0, 3)).toEqual([24, 24, 27])
})
test("renders assistant LaTeX as Unicode text without changing code", async () => {
setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" })
const app = mount(setup)
@@ -2102,7 +2188,7 @@ describe("NanobotTui layout", () => {
syntaxStyle: { getStyle(name: string): { fg?: { toInts(): number[] } } | undefined } | null
}
transcript: {
markdown: Set<{ syntaxStyle: object }>
markdown: Set<{ fg?: { toInts(): number[] }; syntaxStyle: object }>
frames: Set<{ borderColor: { toInts(): number[] } }>
userRows: Set<{ backgroundColor: { intent: string; toInts(): number[] } }>
userMessages: Set<{ renderable: TextRenderable }>
@@ -2136,6 +2222,7 @@ describe("NanobotTui layout", () => {
expect(internals.composer.textColor.toInts().slice(0, 3)).toEqual([24, 24, 27])
expect(sessionFrame?.borderColor.toInts().slice(0, 3)).toEqual([212, 212, 216])
expect(userRow?.backgroundColor.toInts().slice(0, 3)).toEqual([240, 240, 240])
expect(markdown?.fg?.toInts().slice(0, 3)).toEqual([24, 24, 27])
expect(markdown?.syntaxStyle).not.toBe(darkSyntax)
expect(internals.composer.syntaxStyle).not.toBe(darkComposerSyntax)
expect(internals.composer.syntaxStyle?.getStyle("image.placeholder")?.fg?.toInts().slice(0, 3))
+17 -3
View File
@@ -1738,16 +1738,30 @@ export class NanobotTui {
key.preventDefault()
return
}
if (!key.ctrl && !key.meta && !key.shift && (key.name === "left" || key.name === "right")) {
if (!key.ctrl && !key.meta && (key.name === "left" || key.name === "right")) {
const direction = key.name === "left" ? -1 : 1
const cursor = this.composerStringCursor()
const target = this.draft.moveImageCursor(
this.composer.plainText,
this.composerStringCursor(),
cursor,
direction,
)
if (target !== null) {
this.composerCursor = target
this.setComposerStringCursor(this.composer.plainText, target)
if (key.shift) {
const cursorOffset = this.composerOffsetForStringIndex(this.composer.plainText, cursor)
const targetOffset = this.composerOffsetForStringIndex(this.composer.plainText, target)
this.composer.setSelection(
Math.min(cursorOffset, targetOffset),
Math.max(cursorOffset, targetOffset),
)
// OpenTUI 0.5.10 clears the selection through the public cursor
// setter. Move the native edit cursor directly so the placeholder
// remains one selected, replaceable unit.
this.composer.editBuffer.setCursorByOffset(targetOffset)
} else {
this.setComposerStringCursor(this.composer.plainText, target)
}
key.preventDefault()
return
}
+5 -1
View File
@@ -183,7 +183,10 @@ export class Transcript {
message.displayContent,
)
}
for (const renderable of this.markdown) renderable.syntaxStyle = theme.syntax
for (const renderable of this.markdown) {
renderable.fg = theme.text
renderable.syntaxStyle = theme.syntax
}
for (const frame of this.frames) frame.borderColor = theme.border
for (const row of this.userRows) {
row.backgroundColor = theme.userBackground
@@ -664,6 +667,7 @@ export class Transcript {
minWidth: 0,
flexGrow: 1,
flexShrink: 1,
fg: this.theme.text,
syntaxStyle: this.theme.syntax,
streaming,
internalBlockMode: "top-level",
+1 -1
View File
@@ -993,7 +993,7 @@ export const ChatList = memo(function ChatList({
) : null}
<span className="min-w-0 flex-1 overflow-hidden">
{projectMode ? (
<span className="relative flex w-full min-w-0 items-baseline gap-2">
<span className="relative flex w-full min-w-0 items-center gap-2">
<SidebarSessionHandle handle={s.handle} />
<span className="min-w-0 flex-1 truncate font-medium leading-5">
{title}
+7
View File
@@ -983,6 +983,7 @@ describe("ChatList", () => {
session({
chatId: "alpha",
title: "Alpha task",
handle: { id: "handle_alpha", name: "mira" },
updatedAt: "2026-05-20T11:00:00Z",
workspaceScope: {
project_path: "/Users/me/nanobot",
@@ -1030,6 +1031,12 @@ describe("ChatList", () => {
"border-sidebar-foreground/10",
);
expect(within(nanobotSection).getByText("Alpha task")).toBeInTheDocument();
expect(
within(nanobotSection)
.getByText("@mira")
.closest("[data-sidebar-session-handle]")
?.parentElement,
).toHaveClass("items-center");
expect(within(nanobotSection).getByText("Zeta task")).toBeInTheDocument();
expect(nanobotText.indexOf("Alpha task")).toBeLessThan(nanobotText.indexOf("Zeta task"));
expect(within(nanobotSection).getByLabelText("Agent running")).toBeInTheDocument();