From 7e60d52f0ed2ab94b34b851855fd8611c8b4ce28 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:54:02 +0800 Subject: [PATCH] fix(tui): prevent terminal cell artifacts --- tui/bun.lock | 20 ++++++++++---------- tui/package.json | 2 +- tui/src/app.test.ts | 34 ++++++++++++++++++++++++++++++++++ tui/src/app.ts | 14 ++++++++++++++ tui/src/picker-menu.ts | 2 ++ tui/src/runtime-controls.ts | 2 ++ 6 files changed, 63 insertions(+), 11 deletions(-) diff --git a/tui/bun.lock b/tui/bun.lock index 4b2ebedb1..9f036a36d 100644 --- a/tui/bun.lock +++ b/tui/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@nanobot/tui", "dependencies": { - "@opentui/core": "0.5.1", + "@opentui/core": "0.5.3", }, "devDependencies": { "@types/bun": "^1.3.13", @@ -14,23 +14,23 @@ }, }, "packages": { - "@opentui/core": ["@opentui/core@0.5.1", "", { "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.1", "@opentui/core-darwin-x64": "0.5.1", "@opentui/core-linux-arm64": "0.5.1", "@opentui/core-linux-arm64-musl": "0.5.1", "@opentui/core-linux-x64": "0.5.1", "@opentui/core-linux-x64-musl": "0.5.1", "@opentui/core-win32-arm64": "0.5.1", "@opentui/core-win32-x64": "0.5.1" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-mIBFyqIP4rkhQ35uldLXWawWQ6S9tvNWvmxGmDJ7W9cLXjegG6gKEfZ/4NyIMma755ERs/sqO/pIh3Ytf3DDFg=="], + "@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-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Yl3JBLYRrBN+SxXY/gYaqCT/JNrN50K4xO7hYC+/Si8/FgOrBlbRmfJIUNQdZMMLUvOMA+I813+hDw3xfarBzQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R39YeUqaMb/rH1h6G4MkB4MLVKIrRaUaXLfVqorZM4xgU5BxnfPetRk1vWR9vuLCvDwskg+kQ589kULw0o6AWA=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-kqMVu+LGuHSCxYFkVJtmuyLLLTMztILSNnlx1eSpHHUiDV4PMc+zkxwRIXO+o0TFTW3gNUKleKUkggriYje7Vw=="], + "@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-linux-arm64": ["@opentui/core-linux-arm64@0.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-PpE1nCHRkxEvSYyZFMToPHjQoVh50A7+BbgetlTX/5ImXzo6iSO83a+7M/1WgZnNu+uZJf5GZKAAcLoRrvQl3Q=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nMo9Q9VIaQVdw2SNKlwIEWMmf3z+cI4jRdCkh36e2RU1FO7LrIBAEmV1ZuRp1CIFVGPkqCXIizCeckZHTr4yQ=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-rmFMtiCm8I0fESB834sTN/ewoI+QDSber588ZO+i08JR6mbv7hkiKW2H/MhiAY1GxGK4nXApleBMyGVOlVDvgQ=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-QOYAxbbWrhYo27Cd6m0ATzpEx9YCAKAq82LgfUn0xu+VKXLNu+Q3hMNSVbG0SepUQQZil5rz228R9o9Cs8995w=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-/CxFxFv+ffMof2nYQrpgEfNkWKkKxYUSfwdt2RdDN5fZRhcxjE949743rV0Oovw5Az63qxPgbyfcZVNVO2HVNg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-hdAYLriLpTj3lvpMyL25GPBzvM2w/n2KCSbIwTmgS2F/dPZYCKJxHEETj2lCvtStSp7KuY8tkg3Xl5RAq1v7gA=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-WO8RjhqKyqW/7P0xHdEVT8JGfU2MO7RlK0kdkNnRSnAEVwsTNd2ibhmKDPLGpo/DKaLuA00CsnrNiLGZZiQJKQ=="], + "@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-win32-arm64": ["@opentui/core-win32-arm64@0.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-AgeTjZbdMxSiuBjyLvcug91qd1Ds6Dlg5z4lCInqL7mPQicDEnKZs5lF2FAaktcU7RPi2wLybbQ/vM0NbpXYmw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-AjObTyZPU0xsK3Yk8GmhkboK6OcMoHBbydqYAybeHD4+v6axScSuZ3OEI9J05JJ9T7H2nZNky75tsdnjsvZJmg=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VttbQHVoZQ5uW5IcQeUHPEx/WFQ2mMflukhhbBjpNSdZOPdzmmC4QGFPQznJVwuzXTnjQ2Nll4AY0ROJ/Q3nkw=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-e3nRlF2nSkLKCUPBF32OL9EDgtQDIh2pBo7tjhumpTyJ3qoNOa3us7DsM290Vw4xnakM6jpk9r9NzRf72CuMVg=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], diff --git a/tui/package.json b/tui/package.json index 9b78ed5ae..0fd7bab74 100644 --- a/tui/package.json +++ b/tui/package.json @@ -10,7 +10,7 @@ "test": "bun test" }, "dependencies": { - "@opentui/core": "0.5.1" + "@opentui/core": "0.5.3" }, "devDependencies": { "@types/bun": "^1.3.13", diff --git a/tui/src/app.test.ts b/tui/src/app.test.ts index 728f9b8f1..fca03c31d 100644 --- a/tui/src/app.test.ts +++ b/tui/src/app.test.ts @@ -646,13 +646,28 @@ describe("NanobotTui layout", () => { runtimeControls: { modelText: TextRenderable accessText: TextRenderable + contextText: TextRenderable visible: boolean menuRoot: { getChildren(): unknown[] } } + titleText: TextRenderable + status: TextRenderable + meta: TextRenderable } try { await waitUntil(() => (app as unknown as { ready: boolean }).ready) + await setup.renderOnce() + expect(setup.renderer.getCursorState()).toMatchObject({ + style: "line", + blinking: false, + }) + expect(ui.runtimeControls.modelText.selectable).toBe(false) + expect(ui.runtimeControls.accessText.selectable).toBe(false) + expect(ui.runtimeControls.contextText.selectable).toBe(false) + expect(ui.titleText.selectable).toBe(false) + expect(ui.status.selectable).toBe(false) + expect(ui.meta.selectable).toBe(false) app.accept({ event: "goal_status", chat_id: "chat", status: "running" }) await setup.flush() await setup.mockMouse.click( @@ -662,6 +677,7 @@ describe("NanobotTui layout", () => { await waitUntil(() => ui.runtimeControls.visible) await setup.flush() const modelRows = ui.runtimeControls.menuRoot.getChildren() as TextRenderable[] + expect(modelRows.every((row) => !row.selectable)).toBe(true) const fast = modelRows.find((row) => row.plainText.includes("fast")) if (!fast) throw new Error("fast model row was not rendered") await setup.mockMouse.click(fast.x + 2, fast.y) @@ -1431,6 +1447,24 @@ describe("NanobotTui layout", () => { expect(setup.renderer.getSelection()).toBeNull() }) + test("clears transcript selection when clicking non-content chrome", async () => { + setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" }) + const app = mount(setup) + const status = (app as unknown as { status: TextRenderable }).status + app.accept({ event: "delta", chat_id: "chat", text: "selectable answer" }) + app.accept({ event: "stream_end", chat_id: "chat" }) + await setup.flush() + + const rows = setup.captureCharFrame().split("\n") + const y = rows.findIndex((row) => row.includes("selectable answer")) + const x = rows[y]?.indexOf("selectable answer") ?? -1 + await setup.mockMouse.drag(x, y, x + "selectable answer".length, y) + expect(setup.renderer.getSelection()?.getSelectedText()).toBe("selectable answer") + + await setup.mockMouse.click(status.x, status.y) + expect(setup.renderer.getSelection()).toBeNull() + }) + test("animates one stable status line while the agent works", async () => { setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" }) const app = mount(setup) diff --git a/tui/src/app.ts b/tui/src/app.ts index b7bfbcc0b..a6e6b5b45 100644 --- a/tui/src/app.ts +++ b/tui/src/app.ts @@ -479,6 +479,14 @@ export class NanobotTui { paddingRight: 1, flexDirection: "column", backgroundColor: RGBA.defaultBackground(), + onMouseDown: (event) => { + // Selection belongs to transcript/input content, never to empty chrome. + // Clearing it here prevents default-background cells from lingering as + // opaque blocks in terminals with differential repainting. + if (event.button === 0 && event.target && !event.target.selectable) { + this.renderer.clearSelection() + } + }, }) this.title = new BoxRenderable(renderer, { id: "nanobot-tui-title", @@ -496,6 +504,7 @@ export class NanobotTui { flexShrink: 0, truncate: true, fg: this.palette.muted, + selectable: false, }) this.runtimeControls = new RuntimeControls( renderer, @@ -553,6 +562,9 @@ export class NanobotTui { backgroundColor: composerSurface, focusedBackgroundColor: composerSurface, cursorColor: this.palette.accent, + // A steady line cursor avoids the block-cell trails produced by some + // terminals when a retained full-screen UI redraws around the composer. + cursorStyle: { style: "line", blinking: false }, showCursor: true, keyBindings: [ { name: "return", shift: true, action: "newline" }, @@ -586,6 +598,7 @@ export class NanobotTui { minWidth: 0, flexGrow: 1, flexShrink: 1, + selectable: false, }) this.meta = new TextRenderable(renderer, { id: "nanobot-tui-meta", @@ -594,6 +607,7 @@ export class NanobotTui { height: 1, width: "auto", flexShrink: 1, + selectable: false, }) const statusRow = new BoxRenderable(renderer, { diff --git a/tui/src/picker-menu.ts b/tui/src/picker-menu.ts index e96da8d2d..46282c20f 100644 --- a/tui/src/picker-menu.ts +++ b/tui/src/picker-menu.ts @@ -119,6 +119,7 @@ export class PickerMenu { width: "100%", height: 1, fg: this.theme.muted, + selectable: false, })) return } @@ -131,6 +132,7 @@ export class PickerMenu { height: 1, wrapMode: "none", fg: selected ? this.theme.text : this.theme.muted, + selectable: false, ...(selected && this.theme.selectedBackground ? { backgroundColor: RGBA.fromHex(this.theme.selectedBackground) } : {}), diff --git a/tui/src/runtime-controls.ts b/tui/src/runtime-controls.ts index cc411bb01..71886a3e4 100644 --- a/tui/src/runtime-controls.ts +++ b/tui/src/runtime-controls.ts @@ -81,6 +81,7 @@ export class RuntimeControls { height: 1, flexShrink: 0, fg: theme.faint, + selectable: false, }) this.render() } @@ -159,6 +160,7 @@ export class RuntimeControls { height: 1, flexShrink: id === "model" ? 1 : 0, fg: this.theme.muted, + selectable: false, onMouseOver: () => { text.fg = this.theme.accent }, onMouseOut: () => this.renderColors(), onMouseDown: (event) => {