mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
179 lines
6.2 KiB
TypeScript
179 lines
6.2 KiB
TypeScript
// Port: none (pure logic in a vm context — no browser, no server).
|
||
//
|
||
// On phones the toolbar and the keyboard accessory bar are `position: fixed`, so
|
||
// they take no layout space: `main`'s padding-bottom is the ONLY thing reserving
|
||
// room for them, and every pixel taken out of it is a pixel of terminal painted
|
||
// underneath them.
|
||
//
|
||
// `_shrinkPaddingToFit` reclaims the sub-row slack left after a keyboard-driven
|
||
// re-fit. It used to take the whole slack, which pulled the terminal's bottom edge
|
||
// down under those bars — and the row the following re-fit gained was painted
|
||
// behind them, clipping the last line of a long wrapped prompt: the bottom half of
|
||
// the text being typed. The floor is now the bars' MEASURED height.
|
||
//
|
||
// Lives outside test/mobile/ deliberately — that suite is Playwright-driven and
|
||
// excluded from `npm run test:ci`, so a regression guarded only there is invisible
|
||
// to CI (same reasoning as terminal-scroll-intent.test.ts).
|
||
import { readFileSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import vm from 'node:vm';
|
||
import { describe, expect, it } from 'vitest';
|
||
|
||
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-handlers.js'), 'utf8');
|
||
|
||
interface Bar {
|
||
offsetHeight: number;
|
||
hidden?: boolean;
|
||
}
|
||
|
||
interface Setup {
|
||
paddingBottom: string;
|
||
containerHeight: number;
|
||
rows: number;
|
||
cellH: number;
|
||
bars: Partial<Record<'.toolbar' | '.keyboard-accessory-bar' | '#cjkInput.cjk-input-visible', Bar>>;
|
||
}
|
||
|
||
/**
|
||
* Load mobile-handlers.js and hand back its KeyboardHandler plus the fake `main`
|
||
* whose inline padding the function edits.
|
||
*
|
||
* `const KeyboardHandler = {...}` is a lexical binding that does not survive to a
|
||
* second `vm.runInContext`, so the export is appended to the SAME script.
|
||
*/
|
||
function loadHandler(setup: Setup) {
|
||
const main = { style: { paddingBottom: setup.paddingBottom } };
|
||
const container = { clientHeight: setup.containerHeight };
|
||
let fits = 0;
|
||
const app = {
|
||
terminal: {
|
||
rows: setup.rows,
|
||
_core: { _renderService: { dimensions: { css: { cell: { height: setup.cellH } } } } },
|
||
},
|
||
fitAddon: {
|
||
fit: () => {
|
||
fits++;
|
||
},
|
||
},
|
||
};
|
||
const context = vm.createContext({
|
||
console,
|
||
app,
|
||
navigator: { userAgent: 'test', maxTouchPoints: 1 },
|
||
window: {
|
||
addEventListener: () => {},
|
||
matchMedia: () => ({ matches: false }),
|
||
scrollTo: () => {},
|
||
getComputedStyle: (el: Bar) => ({ display: el.hidden ? 'none' : 'block', visibility: 'visible' }),
|
||
},
|
||
document: {
|
||
body: { classList: { add: () => {}, remove: () => {} } },
|
||
addEventListener: () => {},
|
||
getElementById: (id: string) => (id === 'terminalContainer' ? container : null),
|
||
querySelector: (sel: string) =>
|
||
sel === '.main' ? main : (setup.bars as Record<string, Bar | undefined>)[sel] || null,
|
||
},
|
||
setTimeout: () => 1,
|
||
clearTimeout: () => {},
|
||
});
|
||
vm.runInContext(`${SOURCE}\nglobalThis.__KH = KeyboardHandler;`, context, { filename: 'mobile-handlers.js' });
|
||
return { handler: (context as { __KH: any }).__KH, main, fits: () => fits };
|
||
}
|
||
|
||
// 10 rows × 19px = 190 in a 200px container → 10px of slack, less than one row.
|
||
const BASE: Setup = {
|
||
paddingBottom: '84px',
|
||
containerHeight: 200,
|
||
rows: 10,
|
||
cellH: 19,
|
||
bars: { '.toolbar': { offsetHeight: 40 }, '.keyboard-accessory-bar': { offsetHeight: 44 } },
|
||
};
|
||
|
||
describe('_shrinkPaddingToFit', () => {
|
||
it('reclaims the slack when the reservation over-reserves', () => {
|
||
// Bars really need 60px, 84 is reserved → the 10px of slack is free to take.
|
||
const { handler, main } = loadHandler({
|
||
...BASE,
|
||
bars: { '.toolbar': { offsetHeight: 30 }, '.keyboard-accessory-bar': { offsetHeight: 30 } },
|
||
});
|
||
|
||
handler._shrinkPaddingToFit();
|
||
|
||
expect(main.style.paddingBottom).toBe('74px');
|
||
});
|
||
|
||
it('never shrinks into the space the bars actually occupy', () => {
|
||
// 40 + 44 = 84: the reservation is exactly right, so there is nothing to take
|
||
// even though the terminal has 10px of slack.
|
||
const { handler, main } = loadHandler(BASE);
|
||
|
||
handler._shrinkPaddingToFit();
|
||
|
||
expect(main.style.paddingBottom).toBe('84px');
|
||
});
|
||
|
||
it('stops part-way when only some of the slack is free', () => {
|
||
// Bars need 78px of the reserved 84 → 6px may be reclaimed, not the full 10.
|
||
const { handler, main } = loadHandler({
|
||
...BASE,
|
||
bars: { '.toolbar': { offsetHeight: 34 }, '.keyboard-accessory-bar': { offsetHeight: 44 } },
|
||
});
|
||
|
||
handler._shrinkPaddingToFit();
|
||
|
||
expect(main.style.paddingBottom).toBe('78px');
|
||
});
|
||
|
||
it('is a no-op, never a grow, when the bars are taller than the reservation', () => {
|
||
// Growing the padding here would resize the terminal as a side effect of a
|
||
// function that exists to reclaim slack.
|
||
const { handler, main } = loadHandler({
|
||
...BASE,
|
||
bars: { '.toolbar': { offsetHeight: 60 }, '.keyboard-accessory-bar': { offsetHeight: 60 } },
|
||
});
|
||
|
||
handler._shrinkPaddingToFit();
|
||
|
||
expect(main.style.paddingBottom).toBe('84px');
|
||
});
|
||
|
||
it('does not count a hidden bar', () => {
|
||
// The accessory bar is display:none until the keyboard opens; counting it
|
||
// would block a reclaim that is genuinely free.
|
||
const { handler, main } = loadHandler({
|
||
...BASE,
|
||
bars: { '.toolbar': { offsetHeight: 40 }, '.keyboard-accessory-bar': { offsetHeight: 44, hidden: true } },
|
||
});
|
||
|
||
handler._shrinkPaddingToFit();
|
||
|
||
expect(main.style.paddingBottom).toBe('74px');
|
||
});
|
||
|
||
it('counts the CJK input strip when it is on screen', () => {
|
||
const { handler, main } = loadHandler({
|
||
...BASE,
|
||
bars: {
|
||
'.toolbar': { offsetHeight: 30 },
|
||
'.keyboard-accessory-bar': { offsetHeight: 30 },
|
||
'#cjkInput.cjk-input-visible': { offsetHeight: 20 },
|
||
},
|
||
});
|
||
|
||
handler._shrinkPaddingToFit();
|
||
|
||
expect(main.style.paddingBottom).toBe('80px');
|
||
});
|
||
|
||
it('leaves the padding alone when the slack is a whole row or more', () => {
|
||
// A full row of slack means the re-fit will claim it as a row; padding is not
|
||
// the lever here.
|
||
const { handler, main, fits } = loadHandler({ ...BASE, containerHeight: 190 + 19 });
|
||
|
||
handler._shrinkPaddingToFit();
|
||
|
||
expect(main.style.paddingBottom).toBe('84px');
|
||
expect(fits()).toBe(0);
|
||
});
|
||
});
|