mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-03 17:52:00 +03:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61b2f169a8 | ||
|
|
54e5c63b7e | ||
|
|
41477c2510 | ||
|
|
0573cefa3b |
+10
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user