fix(tui): prevent terminal cell artifacts

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent a632017194
commit 7e60d52f0e
6 changed files with 63 additions and 11 deletions
+34
View File
@@ -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)
+14
View File
@@ -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, {
+2
View File
@@ -119,6 +119,7 @@ export class PickerMenu<T> {
width: "100%",
height: 1,
fg: this.theme.muted,
selectable: false,
}))
return
}
@@ -131,6 +132,7 @@ export class PickerMenu<T> {
height: 1,
wrapMode: "none",
fg: selected ? this.theme.text : this.theme.muted,
selectable: false,
...(selected && this.theme.selectedBackground
? { backgroundColor: RGBA.fromHex(this.theme.selectedBackground) }
: {}),
+2
View File
@@ -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) => {