From 58339e272f0453ac3e6c43605ee61f8e4577772b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 26 Jun 2026 14:35:33 +0900 Subject: [PATCH] fix(tui): reset SGR state after row clears --- packages/tui/src/tui.ts | 16 ++--- .../tui/test/tui-render-sgr-reset.test.ts | 61 +++++++++++++++++++ packages/tui/test/tui-render.test.ts | 2 +- 3 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 packages/tui/test/tui-render-sgr-reset.test.ts diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 6f2fec38b..31a877dc1 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1307,7 +1307,7 @@ export class TUI extends Container { const firstInsertedScreenRow = regionBottom - plan.insertedRows.length + 1; for (let index = 0; index < plan.insertedRows.length; index++) { const screenRow = firstInsertedScreenRow + index; - buffer += `\x1b[${screenRow + 1};1H\x1b[2K`; + buffer += `\x1b[${screenRow + 1};1H\x1b[2K${TUI.SEGMENT_RESET}`; buffer += plan.insertedRows[index] ?? ""; } @@ -1345,7 +1345,7 @@ export class TUI extends Container { const bufferLength = Math.max(height, newLines.length); for (let row = 0; row < bufferLength; row++) { if (row > 0) buffer += "\r\n"; - buffer += "\r\x1b[2K"; + buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`; buffer += newLines[row] ?? ""; } @@ -1627,7 +1627,7 @@ export class TUI extends Container { buffer += `\x1b[${clearStartOffset}B`; } for (let i = 0; i < extraLines; i++) { - buffer += "\r\x1b[2K"; + buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`; if (i < extraLines - 1) buffer += "\x1b[1B"; } const moveBack = Math.max(0, extraLines - 1 + clearStartOffset); @@ -1699,7 +1699,7 @@ export class TUI extends Container { for (let row = 0; row < height; row++) { if (row > 0) buffer += "\r\n"; - buffer += "\r\x1b[2K"; + buffer += `\r\x1b[2K${TUI.SEGMENT_RESET}`; buffer += newLines[viewportTop + row] ?? ""; } @@ -1769,9 +1769,9 @@ export class TUI extends Container { return; } - buffer += "\x1b[2K"; + buffer += `\x1b[2K${TUI.SEGMENT_RESET}`; for (let row = 1; row < imageReservedRows; row++) { - buffer += "\r\n\x1b[2K"; + buffer += `\r\n\x1b[2K${TUI.SEGMENT_RESET}`; } buffer += `\x1b[${imageReservedRows - 1}A`; buffer += line; @@ -1780,7 +1780,7 @@ export class TUI extends Container { continue; } - buffer += "\x1b[2K"; // Clear current line + buffer += `\x1b[2K${TUI.SEGMENT_RESET}`; // Clear current line if (!isImage && visibleWidth(line) > width) { // Log all lines to crash file for debugging const crashLogPath = path.join(os.homedir(), ".senpi", "agent", "senpi-crash.log"); @@ -1825,7 +1825,7 @@ export class TUI extends Container { } const extraLines = this.previousLines.length - newLines.length; for (let i = newLines.length; i < this.previousLines.length; i++) { - buffer += "\r\n\x1b[2K"; + buffer += `\r\n\x1b[2K${TUI.SEGMENT_RESET}`; } // Move cursor back to end of new content buffer += `\x1b[${extraLines}A`; diff --git a/packages/tui/test/tui-render-sgr-reset.test.ts b/packages/tui/test/tui-render-sgr-reset.test.ts new file mode 100644 index 000000000..ac02bf60c --- /dev/null +++ b/packages/tui/test/tui-render-sgr-reset.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class TestComponent implements Component { + lines: string[] = []; + + render(_width: number): string[] { + return this.lines; + } + + invalidate(): void {} +} + +class LoggingVirtualTerminal extends VirtualTerminal { + private writes: string[] = []; + + override write(data: string): void { + this.writes.push(data); + super.write(data); + } + + getWrites(): string { + return this.writes.join(""); + } + + clearWrites(): void { + this.writes = []; + } +} + +describe("TUI changed-row SGR repaint", () => { + it("resets SGR state before clearing and repainting changed rows", async () => { + const terminal = new LoggingVirtualTerminal(72, 6); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["header", "\x1b[38;2;9;131;232mWorking\x1b[0m", "footer", "input"]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + component.lines = ["header", "\x1b[38;2;9;131;232mWorking harder\x1b[0m", "footer", "input"]; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok( + writes.includes("\x1b[2K\x1b[0m\x1b]8;;\x07\x1b[38;2;9;131;232mWorking harder"), + "changed-row repaint should reset terminal style state immediately after clearing the row", + ); + assert.ok( + !writes.includes("\x1b[2K\x1b[38;2;9;131;232m"), + "repaint should not write truecolor text directly after CSI 2K", + ); + + tui.stop(); + }); +}); diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index d725a21fb..4f3cf36bd 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -322,7 +322,7 @@ describe("TUI Kitty image cleanup", () => { const writes = terminal.getWrites(); assert.ok( - writes.includes(`\x1b[2K\r\n\x1b[2K\x1b[1A${imageSequence}\x1b[1B`), + writes.includes(`\x1b[2K\x1b[0m\x1b]8;;\x07\r\n\x1b[2K\x1b[0m\x1b]8;;\x07\x1b[1A${imageSequence}\x1b[1B`), "reserved rows should be cleared before the image placement is drawn", ); assert.ok(