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 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) @pytest.fixture(scope="session", autouse=True)
def _use_windows_system_ca_for_default_http_clients() -> Iterator[None]: def _use_windows_system_ca_for_default_http_clients() -> Iterator[None]:
"""Avoid reparsing certifi's CA bundle for every offline HTTP client. """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, sender_id: str,
ttl: int = _TTL_DEFAULT_S, ttl: int = _TTL_DEFAULT_S,
) -> str: ) -> 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"``). Returns the code (e.g. ``"ABCD-EFGH"``).
""" """
with _LOCK: with _LOCK:
data = _load() data = _load()
_gc_pending(data) _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)) raw = "".join(secrets.choice(_ALPHABET) for _ in range(_CODE_LENGTH))
code = f"{raw[:4]}-{raw[4:]}" code = f"{raw[:4]}-{raw[4:]}"
data.setdefault("pending", {})[code] = { data.setdefault("pending", {})[code] = {
"channel": channel, "channel": channel,
"sender_id": str(sender_id), "sender_id": sender,
"created_at": time.time(), "created_at": time.time(),
"expires_at": time.time() + ttl, "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)} codes = {store.generate_code("telegram", str(i)) for i in range(20)}
assert len(codes) == 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: def test_ttl_expiration(self, monkeypatch) -> None:
clock = {"now": 1_000.0} clock = {"now": 1_000.0}
monkeypatch.setattr(store.time, "time", lambda: clock["now"]) monkeypatch.setattr(store.time, "time", lambda: clock["now"])
+10 -10
View File
@@ -5,7 +5,7 @@
"": { "": {
"name": "@nanobot/tui", "name": "@nanobot/tui",
"dependencies": { "dependencies": {
"@opentui/core": "0.5.3", "@opentui/core": "0.5.10",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.13", "@types/bun": "^1.3.13",
@@ -14,23 +14,23 @@
}, },
}, },
"packages": { "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=="], "@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" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@opentui/core": "0.5.3" "@opentui/core": "0.5.10"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.13", "@types/bun": "^1.3.13",
+88 -1
View File
@@ -9,6 +9,7 @@ import {
} from "@opentui/core" } from "@opentui/core"
import { import {
MockTreeSitterClient, MockTreeSitterClient,
TestRecorder,
createTestRenderer, createTestRenderer,
type TestRendererSetup, type TestRendererSetup,
} from "@opentui/core/testing" } from "@opentui/core/testing"
@@ -491,6 +492,17 @@ describe("NanobotTui layout", () => {
expect(ui.composer.plainText).toContain("replacement") expect(ui.composer.plainText).toContain("replacement")
expect(ui.composer.plainText).not.toContain("Image #1") 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("") ui.composer.setText("")
setup.mockInput.pressKey("v", { ctrl: true }) setup.mockInput.pressKey("v", { ctrl: true })
await waitUntil(() => ui.composer.plainText === "[Image #1] ") 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 () => { test("renders assistant LaTeX as Unicode text without changing code", async () => {
setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" })
const app = mount(setup) const app = mount(setup)
@@ -2102,7 +2188,7 @@ describe("NanobotTui layout", () => {
syntaxStyle: { getStyle(name: string): { fg?: { toInts(): number[] } } | undefined } | null syntaxStyle: { getStyle(name: string): { fg?: { toInts(): number[] } } | undefined } | null
} }
transcript: { transcript: {
markdown: Set<{ syntaxStyle: object }> markdown: Set<{ fg?: { toInts(): number[] }; syntaxStyle: object }>
frames: Set<{ borderColor: { toInts(): number[] } }> frames: Set<{ borderColor: { toInts(): number[] } }>
userRows: Set<{ backgroundColor: { intent: string; toInts(): number[] } }> userRows: Set<{ backgroundColor: { intent: string; toInts(): number[] } }>
userMessages: Set<{ renderable: TextRenderable }> userMessages: Set<{ renderable: TextRenderable }>
@@ -2136,6 +2222,7 @@ describe("NanobotTui layout", () => {
expect(internals.composer.textColor.toInts().slice(0, 3)).toEqual([24, 24, 27]) expect(internals.composer.textColor.toInts().slice(0, 3)).toEqual([24, 24, 27])
expect(sessionFrame?.borderColor.toInts().slice(0, 3)).toEqual([212, 212, 216]) expect(sessionFrame?.borderColor.toInts().slice(0, 3)).toEqual([212, 212, 216])
expect(userRow?.backgroundColor.toInts().slice(0, 3)).toEqual([240, 240, 240]) 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(markdown?.syntaxStyle).not.toBe(darkSyntax)
expect(internals.composer.syntaxStyle).not.toBe(darkComposerSyntax) expect(internals.composer.syntaxStyle).not.toBe(darkComposerSyntax)
expect(internals.composer.syntaxStyle?.getStyle("image.placeholder")?.fg?.toInts().slice(0, 3)) 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() key.preventDefault()
return 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 direction = key.name === "left" ? -1 : 1
const cursor = this.composerStringCursor()
const target = this.draft.moveImageCursor( const target = this.draft.moveImageCursor(
this.composer.plainText, this.composer.plainText,
this.composerStringCursor(), cursor,
direction, direction,
) )
if (target !== null) { if (target !== null) {
this.composerCursor = target 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() key.preventDefault()
return return
} }
+5 -1
View File
@@ -183,7 +183,10 @@ export class Transcript {
message.displayContent, 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 frame of this.frames) frame.borderColor = theme.border
for (const row of this.userRows) { for (const row of this.userRows) {
row.backgroundColor = theme.userBackground row.backgroundColor = theme.userBackground
@@ -664,6 +667,7 @@ export class Transcript {
minWidth: 0, minWidth: 0,
flexGrow: 1, flexGrow: 1,
flexShrink: 1, flexShrink: 1,
fg: this.theme.text,
syntaxStyle: this.theme.syntax, syntaxStyle: this.theme.syntax,
streaming, streaming,
internalBlockMode: "top-level", internalBlockMode: "top-level",
+1 -1
View File
@@ -993,7 +993,7 @@ export const ChatList = memo(function ChatList({
) : null} ) : null}
<span className="min-w-0 flex-1 overflow-hidden"> <span className="min-w-0 flex-1 overflow-hidden">
{projectMode ? ( {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} /> <SidebarSessionHandle handle={s.handle} />
<span className="min-w-0 flex-1 truncate font-medium leading-5"> <span className="min-w-0 flex-1 truncate font-medium leading-5">
{title} {title}
+7
View File
@@ -983,6 +983,7 @@ describe("ChatList", () => {
session({ session({
chatId: "alpha", chatId: "alpha",
title: "Alpha task", title: "Alpha task",
handle: { id: "handle_alpha", name: "mira" },
updatedAt: "2026-05-20T11:00:00Z", updatedAt: "2026-05-20T11:00:00Z",
workspaceScope: { workspaceScope: {
project_path: "/Users/me/nanobot", project_path: "/Users/me/nanobot",
@@ -1030,6 +1031,12 @@ describe("ChatList", () => {
"border-sidebar-foreground/10", "border-sidebar-foreground/10",
); );
expect(within(nanobotSection).getByText("Alpha task")).toBeInTheDocument(); 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(within(nanobotSection).getByText("Zeta task")).toBeInTheDocument();
expect(nanobotText.indexOf("Alpha task")).toBeLessThan(nanobotText.indexOf("Zeta task")); expect(nanobotText.indexOf("Alpha task")).toBeLessThan(nanobotText.indexOf("Zeta task"));
expect(within(nanobotSection).getByLabelText("Agent running")).toBeInTheDocument(); expect(within(nanobotSection).getByLabelText("Agent running")).toBeInTheDocument();