Files
Rounak DattaandClaude Opus 5 ba843bb272 fix(mobile): keep a long prompt visible instead of hiding it behind the keyboard
Typing a prompt long enough to wrap ran the text off the bottom of the screen: the
tail — the part being typed, where the cursor is — sat behind the on-screen
keyboard, so the user was typing blind. Two independent causes.

**The overlay had no bottom bound.** On touch devices keystrokes are buffered in
the local-echo overlay and do not reach the PTY until Enter, so the CLI never
learns the prompt is long and nothing scrolls or reflows to make room. Meanwhile
the renderer lays its wrapped lines out straight DOWNWARD from the prompt row
(`top = promptRow * cellH`, each line at `i * cellH`) with nothing clamping it to
the visible rows — and with the keyboard up there are only a handful of those.

The block now grows UPWARD once it would pass the last visible row: it is lifted
so its final line lands ON that row. Every line div is opaque, so it covers
transcript above rather than vanishing under the keyboard below — the same thing a
real terminal does when a composer expands. A prompt taller than the whole
viewport keeps its TAIL, for the same reason the fix exists: the end is what the
user is looking at. `startCol` indents only the line that starts at the prompt
marker, so it is dropped along with that line when only the tail fits, and the
cursor follows the last VISIBLE line.

`rows` joins the render key: the layout depends on it, so a keyboard opening —
which changes rows without changing the text — must not be skipped as a redundant
render.

**`_shrinkPaddingToFit()` was reclaiming the bars' own space.** On phones the
toolbar and accessory bar are `position: fixed`, so they occupy no layout space
and `main`'s padding-bottom is the ONLY thing reserving room for them. Shrinking
it by the full sub-row slack pulled the terminal's bottom edge down underneath
them, and the row the following re-fit gained was painted behind them — clipping
the last line of a long prompt. The shrink now has a floor: the MEASURED height of
the currently-visible fixed bars, so genuine over-reservation of the hard-coded
84px is still reclaimed while a device that needs those pixels keeps them. The
floor is `Math.min(currentPadding, measured)`, so it can only ever prevent a
shrink, never cause a grow that would resize the terminal as a side effect.

Overlay behaviour lives in `packages/xterm-zerolag-input/` (single-source; the
vendor bundles are generated), so the fix is in the package with the row count
passed in as an optional `totalRows` — absent, the layout is exactly as before.

Tests: 7 cases in the package's `overlay-renderer.test.ts` (upward lift, tail
retention, indent drop, cursor on the last visible line, and the unclamped
fallbacks) and 7 in a new `test/mobile-keyboard-bottom-padding.test.ts` (reclaim,
floor, partial reclaim, no-grow, hidden bars, CJK strip, whole-row slack). 5 and 4
of them respectively fail without the fix. Package suite 238 pass, including the
codex byte-identity and replay tests.

Verified on Android + Chrome against a live instance: a ~460-character prompt
wrapping ~12 rows stays on screen while typing and arrives at the PTY intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:14:52 +00:00

506 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js';
import type { RenderParams, FontStyle } from '../src/types.js';
const FONT: FontStyle = {
fontFamily: 'monospace',
fontSize: '14px',
fontWeight: 'normal',
color: '#eeeeee',
backgroundColor: '#0d0d0d',
letterSpacing: '',
};
function makeParams(overrides: Partial<RenderParams> = {}): RenderParams {
return {
lines: ['hello'],
startCol: 2,
totalCols: 80,
cellW: 8.4,
cellH: 17,
charTop: 2,
charHeight: 14,
promptRow: 10,
font: FONT,
showCursor: true,
cursorColor: '#e0e0e0',
...overrides,
};
}
describe('renderOverlay', () => {
it('positions container at prompt row', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ promptRow: 5 }));
expect(container.style.top).toBe(5 * 17 + 'px');
expect(container.style.left).toBe('0px');
});
it('creates per-character spans in a line div', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'] }));
// Line div + cursor span
expect(container.children.length).toBe(2);
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(3); // a, b, c
const spanA = lineDiv.children[0] as HTMLSpanElement;
expect(spanA.textContent).toBe('a');
expect(spanA.style.left).toBe('0px');
const spanB = lineDiv.children[1] as HTMLSpanElement;
expect(spanB.textContent).toBe('b');
expect(spanB.style.left).toBe('8.4px');
const spanC = lineDiv.children[2] as HTMLSpanElement;
expect(spanC.textContent).toBe('c');
expect(spanC.style.left).toBe('16.8px');
});
it('sets span width to cellW', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['x'], cellW: 9.5 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.width).toBe('9.5px');
});
it('applies font styles to spans', () => {
const font: FontStyle = {
fontFamily: 'Fira Code',
fontSize: '16px',
fontWeight: 'bold',
color: '#ff0000',
backgroundColor: '#000000',
letterSpacing: '0.5px',
};
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['A'], font }));
const lineDiv = container.children[0] as HTMLDivElement;
// jsdom normalizes hex to rgb()
expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)');
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.fontFamily).toBe('Fira Code');
expect(span.style.fontSize).toBe('16px');
expect(span.style.fontWeight).toBe('bold');
expect(span.style.color).toBe('rgb(255, 0, 0)');
expect(span.style.letterSpacing).toBe('0.5px');
});
it('offsets first line by startCol', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['hi'], startCol: 5, cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement;
// First line left = startCol * cellW
expect(lineDiv.style.left).toBe('50px');
});
it('renders cursor at end of text', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['ab'],
startCol: 3,
cellW: 10,
cellH: 20,
showCursor: true,
cursorColor: '#ff00ff',
})
);
// Last child is cursor (after line div)
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
// cursorCol = startCol(3) + text.length(2) = 5
expect(cursor.style.left).toBe('50px');
expect(cursor.style.width).toBe('10px');
expect(cursor.style.height).toBe('20px');
// jsdom normalizes hex to rgb()
expect(cursor.style.backgroundColor).toBe('rgb(255, 0, 255)');
});
it('does not render cursor when showCursor is false', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false }));
// Only line div, no cursor
expect(container.children.length).toBe(1);
});
it('renders multi-line text', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['first', 'second'],
startCol: 5,
cellW: 10,
cellH: 20,
})
);
// 2 line divs + cursor
expect(container.children.length).toBe(3);
const line1 = container.children[0] as HTMLDivElement;
expect(line1.style.left).toBe('50px'); // startCol * cellW
expect(line1.style.top).toBe('0px');
expect(line1.children.length).toBe(5); // 'first'
const line2 = container.children[1] as HTMLDivElement;
expect(line2.style.left).toBe('0px'); // wrapped lines start at col 0
expect(line2.style.top).toBe('20px'); // second row
expect(line2.children.length).toBe(6); // 'second'
});
it('clears previous content on re-render', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'] }));
expect(container.children.length).toBe(2); // line + cursor
renderOverlay(container, makeParams({ lines: ['xy'] }));
expect(container.children.length).toBe(2); // line + cursor (rebuilt)
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(2); // x, y
});
it('shows container (display not none)', () => {
const container = document.createElement('div');
container.style.display = 'none';
renderOverlay(container, makeParams());
expect(container.style.display).toBe('');
});
// ─── Anti-flicker / compositing seam tests ────────────────────
it('line div height extends 1px past cellH to cover compositing seam', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
// cellH + 1 = 20px — the extra 1px covers the compositing seam
expect(lineDiv.style.height).toBe('20px');
});
it('line div height is cellH+1 for various cell heights', () => {
for (const cellH of [15, 17, 19, 22]) {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['x'], cellH }));
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.style.height).toBe(cellH + 1 + 'px');
}
});
it('multi-line overlay has cellH+1 height on each line div', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['first', 'second'],
cellH: 19,
})
);
const line1 = container.children[0] as HTMLDivElement;
const line2 = container.children[1] as HTMLDivElement;
expect(line1.style.height).toBe('20px');
expect(line2.style.height).toBe('20px');
});
// ─── Span vertical centering tests ────────────────────────────
it('span uses full cellH for height and lineHeight (CSS centering)', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.height).toBe('19px');
expect(span.style.lineHeight).toBe('19px');
});
it('span top is 0px (no vertical offset / no transform)', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.top).toBe('0px');
// No translateY transform — sub-pixel overhang causes artifacts
expect(span.style.transform).toBe('');
});
// ─── Font rendering tests ─────────────────────────────────────
it('span disables ligatures via font-feature-settings', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['fi'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
// Check cssText includes the ligature-disabling settings
// jsdom may normalize whitespace; check that both liga and calt are disabled
expect(span.style.cssText).toContain('font-feature-settings:');
expect(span.style.cssText).toContain("'liga' 0");
expect(span.style.cssText).toContain("'calt' 0");
});
it('span has text-align: center for glyph centering', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['m'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.textAlign).toBe('center');
});
it('span has pointer-events: none', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.pointerEvents).toBe('none');
});
// ─── Multi-line cursor positioning ────────────────────────────
it('cursor on wrapped line uses col 0 as base', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['first', 'ab'],
startCol: 5,
cellW: 10,
cellH: 20,
showCursor: true,
})
);
// Cursor at end of second line: col = 0 + 2 = 2
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('20px'); // 2 * 10
expect(cursor.style.top).toBe('20px'); // row 1 * cellH
});
// ─── charTop/charHeight passed through ────────────────────────
it('accepts charTop and charHeight params without error', () => {
const container = document.createElement('div');
expect(() =>
renderOverlay(
container,
makeParams({
lines: ['test'],
charTop: 2,
charHeight: 14,
})
)
).not.toThrow();
expect(container.children.length).toBeGreaterThan(0);
});
// ─── Line div positioning regression ──────────────────────────
it('line div background color matches font.backgroundColor', () => {
const container = document.createElement('div');
const font: FontStyle = { ...FONT, backgroundColor: '#1a1a1a' };
renderOverlay(container, makeParams({ lines: ['x'], font }));
const lineDiv = container.children[0] as HTMLDivElement;
// jsdom normalizes hex to rgb()
expect(lineDiv.style.backgroundColor).toBe('rgb(26, 26, 26)');
});
it('empty line produces line div with no spans', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: [''] }));
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(0);
});
// ─── CJK wide character support ───────────────────────────────
it('CJK characters get double-width spans', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a你b'], cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(3);
const spanA = lineDiv.children[0] as HTMLSpanElement;
expect(spanA.textContent).toBe('a');
expect(spanA.style.left).toBe('0px');
expect(spanA.style.width).toBe('10px'); // 1 cell
const spanCJK = lineDiv.children[1] as HTMLSpanElement;
expect(spanCJK.textContent).toBe('你');
expect(spanCJK.style.left).toBe('10px'); // col 1
expect(spanCJK.style.width).toBe('20px'); // 2 cells
const spanB = lineDiv.children[2] as HTMLSpanElement;
expect(spanB.textContent).toBe('b');
expect(spanB.style.left).toBe('30px'); // col 3
expect(spanB.style.width).toBe('10px'); // 1 cell
});
it('cursor position accounts for CJK width', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({
lines: ['你好'],
startCol: 2,
cellW: 10,
showCursor: true,
})
);
// 你(2) + 好(2) = 4 visual cols, cursor at startCol(2) + 4 = 6
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('60px');
});
it('mixed ASCII and CJK characters position correctly', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['hi你'], cellW: 8 }));
const lineDiv = container.children[0] as HTMLDivElement;
// h(col 0), i(col 1), 你(col 2, width 2)
const spanH = lineDiv.children[0] as HTMLSpanElement;
expect(spanH.style.left).toBe('0px');
const spanI = lineDiv.children[1] as HTMLSpanElement;
expect(spanI.style.left).toBe('8px');
const spanCJK = lineDiv.children[2] as HTMLSpanElement;
expect(spanCJK.style.left).toBe('16px');
expect(spanCJK.style.width).toBe('16px');
});
});
describe('charCellWidth', () => {
it('returns 1 for ASCII characters', () => {
expect(charCellWidth(null, 'a')).toBe(1);
expect(charCellWidth(null, '!')).toBe(1);
expect(charCellWidth(null, ' ')).toBe(1);
});
it('returns 2 for CJK ideographs', () => {
expect(charCellWidth(null, '你')).toBe(2);
expect(charCellWidth(null, '好')).toBe(2);
expect(charCellWidth(null, '中')).toBe(2);
});
it('returns 2 for Japanese hiragana', () => {
expect(charCellWidth(null, 'こ')).toBe(2);
expect(charCellWidth(null, 'ん')).toBe(2);
});
it('returns 2 for Korean syllables', () => {
expect(charCellWidth(null, '안')).toBe(2);
expect(charCellWidth(null, '녕')).toBe(2);
});
it('returns 2 for fullwidth forms', () => {
expect(charCellWidth(null, '\uff01')).toBe(2); // !
expect(charCellWidth(null, '\uff21')).toBe(2); // A
});
it('uses terminal unicode addon when available', () => {
const mockTerminal = {
unicode: { getStringCellWidth: (s: string) => (s === 'W' ? 2 : 1) },
} as any;
expect(charCellWidth(mockTerminal, 'W')).toBe(2);
expect(charCellWidth(mockTerminal, 'n')).toBe(1);
});
});
describe('stringCellWidth', () => {
it('sums individual character widths', () => {
expect(stringCellWidth(null, 'abc')).toBe(3);
expect(stringCellWidth(null, '你好')).toBe(4);
expect(stringCellWidth(null, 'a你b')).toBe(4);
});
it('returns 0 for empty string', () => {
expect(stringCellWidth(null, '')).toBe(0);
});
});
describe('renderOverlay — staying on screen (totalRows)', () => {
// A phone with the keyboard up leaves only a handful of terminal rows. The
// overlay lays its wrapped lines out downward from the prompt row, so a long
// prompt used to run off the bottom edge and the user typed blind, with the
// tail of their own sentence behind the keyboard. With totalRows known, the
// composer grows UPWARD instead — the line divs are opaque, so they cover
// transcript above rather than disappearing below.
const linesOf = (n: number) => Array.from({ length: n }, (_, i) => `line${i}`);
const lineDivs = (container: HTMLDivElement) =>
Array.from(container.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
it('lifts the block so its last line lands on the last visible row', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: linesOf(5), promptRow: 10, totalRows: 12, cellH: 17 }));
// 10 + 5 would end on row 14 of a 12-row screen; the block starts at 7 instead.
expect(container.style.top).toBe(7 * 17 + 'px');
expect(lineDivs(container)).toHaveLength(5);
});
it('leaves the prompt row alone when the block already fits', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: linesOf(3), promptRow: 5, totalRows: 24, cellH: 17 }));
expect(container.style.top).toBe(5 * 17 + 'px');
});
it('keeps the TAIL when the prompt is taller than the whole viewport', () => {
// The end is where the cursor is, and where the user is looking.
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: linesOf(6), promptRow: 2, totalRows: 3, cellH: 20 }));
const divs = lineDivs(container);
expect(container.style.top).toBe('0px');
expect(divs).toHaveLength(3);
expect(divs.map((d) => d.textContent)).toEqual(['line3', 'line4', 'line5']);
});
it('drops the prompt indent once the prompt line is no longer shown', () => {
// startCol indents only the line that begins at the prompt marker.
const container = document.createElement('div');
renderOverlay(
container,
makeParams({ lines: linesOf(6), promptRow: 2, totalRows: 3, startCol: 5, cellW: 10, totalCols: 80 })
);
const first = lineDivs(container)[0];
expect(first.style.left).toBe('0px');
expect(first.style.width).toBe(80 * 10 + 'px');
});
it('rides the cursor on the last VISIBLE line', () => {
const container = document.createElement('div');
renderOverlay(
container,
makeParams({ lines: ['aaa', 'bbb', 'ccc', 'ddd'], promptRow: 9, totalRows: 3, cellH: 20, cellW: 10, startCol: 4 })
);
const cursor = Array.from(container.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement;
// Tail is the last 3 lines, so the cursor sits on row 2 (0-based) of the block…
expect(cursor.style.top).toBe(2 * 20 + 'px');
// …at column 3, NOT startCol + 3: the indented prompt line is not shown.
expect(cursor.style.left).toBe(3 * 10 + 'px');
});
it('lays out straight down when totalRows is absent (unchanged behaviour)', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: linesOf(9), promptRow: 20, cellH: 17 }));
expect(container.style.top).toBe(20 * 17 + 'px');
expect(lineDivs(container)).toHaveLength(9);
});
it('falls back to the terminal row count when totalRows is not passed', () => {
// The addon passes totalRows, but a stale bundle / third-party caller may not.
const container = document.createElement('div');
renderOverlay(
container,
makeParams({ lines: linesOf(4), promptRow: 8, cellH: 17, terminal: { rows: 10, cols: 80 } as never })
);
expect(container.style.top).toBe(6 * 17 + 'px');
});
});