fix(tui): reset styles per line

This commit is contained in:
Mario Zechner 2026-01-11 18:36:48 +01:00
parent 741262d89d
commit 6d495348c5
5 changed files with 37 additions and 0 deletions

View file

@ -24,6 +24,8 @@ interface Component {
| `handleInput?(data)` | Receive keyboard input when component has focus. |
| `invalidate?()` | Clear cached render state. |
The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line.
## Using Components
**In hooks** via `ctx.ui.custom()`:

View file

@ -10,6 +10,7 @@
### Fixed
- Cursor now moves to end of content on exit, preventing status line from being overwritten ([#629](https://github.com/badlogic/pi-mono/pull/629) by [@tallshort](https://github.com/tallshort))
- Reset ANSI styles after each rendered line to prevent style leakage
## [0.42.5] - 2026-01-11

View file

@ -74,6 +74,8 @@ interface Component {
| `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). |
| `invalidate?()` | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call. |
The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line.
## Built-in Components
### Container

View file

@ -289,6 +289,11 @@ export class TUI extends Container {
private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
private applyLineResets(lines: string[]): string[] {
const reset = TUI.SEGMENT_RESET;
return lines.map((line) => (this.containsImage(line) ? line : line + reset));
}
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
private compositeLineAt(
baseLine: string,
@ -343,6 +348,8 @@ export class TUI extends Container {
newLines = this.compositeOverlays(newLines, width, height);
}
newLines = this.applyLineResets(newLines);
// Width changed - need full re-render
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;

View file

@ -1,5 +1,6 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { type Component, TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
@ -11,6 +12,16 @@ class TestComponent implements Component {
invalidate(): void {}
}
function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number {
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
const buffer = xterm.buffer.active;
const line = buffer.getLine(buffer.viewportY + row);
assert.ok(line, `Missing buffer line at row ${row}`);
const cell = line.getCell(col);
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
return cell.isItalic();
}
describe("TUI differential rendering", () => {
it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => {
const terminal = new VirtualTerminal(40, 10);
@ -68,6 +79,20 @@ describe("TUI differential rendering", () => {
tui.stop();
});
it("resets styles after each rendered line", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["\x1b[3mItalic", "Plain"];
tui.start();
await terminal.flush();
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
tui.stop();
});
it("renders correctly when first line changes but rest stays same", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);