From 16937947bee9db9466cfc4aa783c2852789bb3d9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 20 Mar 2026 19:35:50 +0100 Subject: [PATCH] fix(tui): skip Termux height redraws closes #2467 --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/tui.ts | 19 ++++- packages/tui/test/tui-render.test.ts | 104 ++++++++++++++++++++++----- 3 files changed, 105 insertions(+), 19 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a96d14d1..e9e1d7ba 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed shared keybinding resolution to stop user overrides from evicting unrelated default shortcuts such as selector confirm and editor cursor keys ([#2455](https://github.com/badlogic/pi-mono/issues/2455)) +- Fixed Termux software keyboard height changes from forcing full-screen redraws and replaying TUI history on every toggle ([#2467](https://github.com/badlogic/pi-mono/issues/2467)) ## [0.61.0] - 2026-03-20 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 1fb2fd39..e6686fce 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -107,6 +107,10 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu return undefined; } +function isTermuxSession(): boolean { + return Boolean(process.env.TERMUX_VERSION); +} + /** * Options for overlay positioning and sizing. * Values can be absolute numbers or percentage strings (e.g., "50%"). @@ -937,9 +941,18 @@ export class TUI extends Container { return; } - // Width or height changed - full re-render - if (widthChanged || heightChanged) { - logRedraw(`terminal size changed (${this.previousWidth}x${this.previousHeight} -> ${width}x${height})`); + // Width changes always need a full re-render because wrapping changes. + if (widthChanged) { + logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`); + fullRender(true); + return; + } + + // Height changes normally need a full re-render to keep the visible viewport aligned, + // but Termux changes height when the software keyboard shows or hides. + // In that environment, a full redraw causes the entire history to replay on every toggle. + if (heightChanged && !isTermuxSession()) { + logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`); fullRender(true); return; } diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 0830bfe4..3570af0c 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -12,6 +12,47 @@ class TestComponent implements Component { 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 = []; + } +} + +async function withEnv(updates: Record, run: () => Promise): Promise { + const previousValues = new Map(); + for (const [key, value] of Object.entries(updates)) { + previousValues.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + try { + return await run(); + } finally { + for (const [key, value] of previousValues) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number { const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; const buffer = xterm.buffer.active; @@ -24,28 +65,59 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num describe("TUI resize handling", () => { it("triggers full re-render when terminal height changes", async () => { - const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); - const component = new TestComponent(); - tui.addChild(component); + await withEnv({ TERMUX_VERSION: undefined }, async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); - component.lines = ["Line 0", "Line 1", "Line 2"]; - tui.start(); - await terminal.flush(); + component.lines = ["Line 0", "Line 1", "Line 2"]; + tui.start(); + await terminal.flush(); - const initialRedraws = tui.fullRedraws; + const initialRedraws = tui.fullRedraws; - // Resize height - terminal.resize(40, 15); - await terminal.flush(); + // Resize height + terminal.resize(40, 15); + await terminal.flush(); - // Should have triggered a full redraw - assert.ok(tui.fullRedraws > initialRedraws, "Height change should trigger full redraw"); + // Should have triggered a full redraw + assert.ok(tui.fullRedraws > initialRedraws, "Height change should trigger full redraw"); - const viewport = terminal.getViewport(); - assert.ok(viewport[0]?.includes("Line 0"), "Content preserved after height change"); + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("Line 0"), "Content preserved after height change"); - tui.stop(); + tui.stop(); + }); + }); + + it("skips full re-render on height changes in Termux", async () => { + await withEnv({ TERMUX_VERSION: "1" }, async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = Array.from({ length: 20 }, (_, i) => `Line ${i}`); + tui.start(); + await terminal.flush(); + terminal.clearWrites(); + + const initialRedraws = tui.fullRedraws; + for (const height of [15, 8, 14, 11]) { + terminal.resize(40, height); + await terminal.flush(); + } + + assert.strictEqual(tui.fullRedraws, initialRedraws, "Height change should not trigger full redraw"); + assert.ok(!terminal.getWrites().includes("\x1b[2J"), "Height change should not clear the screen"); + assert.ok(!terminal.getWrites().includes("\x1b[3J"), "Height change should not clear scrollback"); + + const viewport = terminal.getViewport(); + assert.ok(viewport.join("\n").includes("Line 19"), "Latest content remains visible after resize"); + + tui.stop(); + }); }); it("triggers full re-render when terminal width changes", async () => {