diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 27f218da..97de67ee 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -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()`: diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 7b2c121b..f6808278 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/README.md b/packages/tui/README.md index 5221564a..4f14a2fb 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -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 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 4b5720d5..648b0ff4 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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; diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index c77f7ede..75469904 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -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);