fix(mobile): hold the PTY rows while the on-screen keyboard is up

Every keyboard open and close resized the PTY, and Claude Code's inline renderer answers each height change by clearing the screen and reprinting only the transcript tail that fits, so rows above that tail were lost from tmux for good. Measured on Claude Code 2.1.306 with tmux 3.2a: five keyboard cycles (62x38 to 62x15 and back) left 75 of 100 numbered lines in the pane's history; with the rows held the PTY stays 62x38, no SIGWINCH reaches the pane and all 100 remain (#672).

While the keyboard is up the rows captured at the show transition are held through getTerminalDimensions, columns still follow the box, and the screen, now taller than its box, is translated up by --kb-clip so the last content row sits at the bottom of the visible area. This is the one documented exception to the columns-only geometry rule: there the extra rows came from another device and nothing kept the composer in view, here they are this device's own rows held on purpose and the clip keeps the composer visible. The local-echo overlay's setRowLimit narrows the renderer's existing totalRows bound, so one place decides where a long draft stops growing downward.

Only inline renderers hold, through a new registry capability keyboardHold: 'inline' (Claude alone) published to the page as window.__codemanKeyboardHoldModes. Full-height layouts such as codex, Claude's fullscreen mode (mouse tracking on, unmeasured), and remote or docker panes resize as before. The hold is released on keyboard close, a shape change, a tab switch, a session update, a reconnect, or the setting turned off. Per-device setting Keep Size Under Keyboard, default on.
This commit is contained in:
JD committed 2026-10-24 20:17:45 -04:00
1 parent 3e06f3c141
commit 3e334357e8
26 files changed
+1226 -14

No files matched your search

+24
View File
@@ -485,6 +485,30 @@ describe('capability shapes', () => {
}, 'transcriptPageKeys is a boolean');
});
it("takes keyboardHold as an optional 'inline', absent meaning the keyboard resizes", () => {
const inline = baseEntry();
(inline.capabilities as Record<string, unknown>).keyboardHold = 'inline';
expect(CliEntrySchema.safeParse(inline).success).toBe(true);
const absent = baseEntry();
delete (absent.capabilities as Record<string, unknown>).keyboardHold;
expect(CliEntrySchema.safeParse(absent).success).toBe(true);
for (const value of [true, 'fullscreen', '']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).keyboardHold = value;
},
`keyboardHold=${JSON.stringify(value)} names no renderer kind`
);
}
});
it('declares keyboardHold for exactly the inline renderer measured losing lines on a height change', () => {
// Claude Code 2.1.306's inline renderer was measured dropping transcript lines from
// tmux on keyboard-driven height changes; every other stock entry resizes.
const holding = STOCK_CLIS.filter((e) => e.capabilities.keyboardHold === 'inline').map((e) => e.id as string);
expect(holding).toEqual(['claude']);
});
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
// Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot
// forward without a version gate. Unmeasured entries stay 'never'.
+619
View File
@@ -0,0 +1,619 @@
// Port: none (pure logic and a real headless xterm in vm contexts: no browser,
// no server).
//
// Rows held under the on-screen keyboard (`keyboardHoldsRows`, per-device,
// default ON). Every PTY height change makes Claude Code clear the screen and
// reprint only the transcript tail that fits, and the keyboard opening and
// closing used to be such a change each time (measured on the dev tier: tmux
// went 62x38 -> 62x15 -> 62x38 with a SIGWINCH per transition). Now the rows
// captured at the show transition are held, columns still follow the box, and
// the over-tall screen is scrolled up instead (keyboardClipPx).
//
// Outside test/mobile/ on purpose: that suite is not in the CI gate.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
import xtermHeadless from '@xterm/headless';
type HeadlessBuffer = {
viewportY: number;
baseY: number;
cursorY: number;
length: number;
getLine(y: number): { translateToString(trim?: boolean): string } | undefined;
};
const { Terminal: HeadlessTerminal } = xtermHeadless as unknown as {
Terminal: new (opts: Record<string, unknown>) => {
rows: number;
write(data: string, cb?: () => void): void;
buffer: { active: HeadlessBuffer };
};
};
const read = (file: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', file), 'utf8');
type Geometry = {
keyboardClipPx(input: Record<string, number | boolean>): number;
keyboardVisibleRowLimit(clipPx: number, viewHeight: number, cellHeight: number): number;
};
function loadGeometry(): Geometry {
const window: Record<string, unknown> = {};
vm.runInContext(read('constants.js'), vm.createContext({ console, window, URLSearchParams }));
return window.CodemanTerminalGeometry as Geometry;
}
describe('keyboardClipPx', () => {
const g = loadGeometry();
// 38 held rows of 17px in a 255px box (15 rows visible): 391px of overflow.
const base = { rows: 38, cellHeight: 17, viewHeight: 255, following: true, viewportY: 100, baseY: 100 };
it('puts the last content row at the bottom of the box', () => {
// Claude: composer and footer run to the last row, cursor in the composer.
expect(g.keyboardClipPx({ ...base, cursorRow: 34, lastContentRow: 37 })).toBe(38 * 17 - 255);
// Content ends at row 20: rows 6..20 are in view.
expect(g.keyboardClipPx({ ...base, cursorRow: 20, lastContentRow: 20 })).toBe(21 * 17 - 255);
});
it('counts a cursor just below the content (an empty prompt line)', () => {
expect(g.keyboardClipPx({ ...base, cursorRow: 22, lastContentRow: 20 })).toBe(23 * 17 - 255);
});
it('ignores a cursor parked far below the content (codex dialogs)', () => {
// Measured on the dev tier: codex's update dialog on rows 1-8 of 42, its
// cursor on row 38 of an otherwise blank screen. Anchoring to the cursor
// scrolled the dialog out of view; the top stays in view instead.
expect(g.keyboardClipPx({ ...base, cursorRow: 37, lastContentRow: 8 })).toBe(0);
});
it('shows the top for a prompt near the top (a fresh shell), and uses the cursor on a blank screen', () => {
expect(g.keyboardClipPx({ ...base, cursorRow: 1, lastContentRow: 1, viewportY: 0, baseY: 0 })).toBe(0);
expect(g.keyboardClipPx({ ...base, cursorRow: 37, lastContentRow: -1 })).toBe(38 * 17 - 255);
});
it('follows the scroll position while reading history, top of scrollback included', () => {
const max = 38 * 17 - 255;
expect(g.keyboardClipPx({ ...base, following: false, viewportY: 0, cursorRow: 37, lastContentRow: 37 })).toBe(0);
expect(g.keyboardClipPx({ ...base, following: false, viewportY: 50, cursorRow: 37, lastContentRow: 37 })).toBe(
Math.round(max / 2)
);
});
it('is 0 when nothing overflows or the terminal cannot be measured', () => {
expect(g.keyboardClipPx({ ...base, viewHeight: 38 * 17, cursorRow: 37, lastContentRow: 37 })).toBe(0);
expect(g.keyboardClipPx({ ...base, cellHeight: 0, cursorRow: 37, lastContentRow: 37 })).toBe(0);
});
it('bounds the overlay to the fully visible rows', () => {
expect(g.keyboardVisibleRowLimit(0, 255, 17)).toBe(15);
expect(g.keyboardVisibleRowLimit(38 * 17 - 255, 255, 17)).toBe(38);
expect(g.keyboardVisibleRowLimit(10, 255, 17)).toBe(15);
});
});
type Proto = Record<string, (this: Record<string, unknown>, ...args: unknown[]) => unknown>;
function loadTerminalProto(keyboard: Record<string, unknown> = { keyboardVisible: true }) {
const windowStub: Record<string, unknown> = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
const context = vm.createContext({
console,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
URLSearchParams,
document: { addEventListener: vi.fn(), documentElement: { dataset: {} }, getElementById: () => null },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: windowStub,
MobileDetection: { isTouchDevice: () => true, isHandheldDevice: () => false, getDeviceType: () => 'mobile' },
KeyboardHandler: keyboard,
});
vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
context
);
const proto = (context as unknown as { __CodemanApp: { prototype: Proto } }).__CodemanApp.prototype;
return { proto, keyboard, windowStub };
}
describe('getTerminalDimensions holds the rows while the keyboard is up', () => {
const { proto, keyboard } = loadTerminalProto();
const fake = (held: number) => ({
_keyboardHeldRows: held,
_keyboardHeldRowsActive: proto._keyboardHeldRowsActive,
fitAddon: { proposeDimensions: () => ({ cols: 62, rows: 15 }) },
});
it('reports the held rows and the container columns', () => {
keyboard.keyboardVisible = true;
expect(proto.getTerminalDimensions.call(fake(38))).toEqual({ cols: 62, rows: 38 });
});
it('follows the container once the keyboard is gone, or with no hold', () => {
keyboard.keyboardVisible = false;
expect(proto.getTerminalDimensions.call(fake(38))).toEqual({ cols: 62, rows: 15 });
keyboard.keyboardVisible = true;
expect(proto.getTerminalDimensions.call(fake(0))).toEqual({ cols: 62, rows: 15 });
});
it('never holds fewer rows than the box offers', () => {
keyboard.keyboardVisible = true;
expect(proto.getTerminalDimensions.call(fake(12))).toEqual({ cols: 62, rows: 15 });
});
it('skips the raw fit while held, so xterm never visits the short size', () => {
keyboard.keyboardVisible = true;
const fit = vi.fn();
const resizes: unknown[] = [];
const self = {
...fake(38),
fitAddon: { fit, proposeDimensions: () => ({ cols: 62, rows: 15 }) },
terminal: { cols: 62, rows: 38, resize: (c: number, r: number) => resizes.push([c, r]) },
getTerminalDimensions: proto.getTerminalDimensions,
_resizeTerminalTo: proto._resizeTerminalTo,
_scheduleOverflowAffordanceSync: () => {},
};
expect(proto.syncTerminalGeometry.call(self)).toEqual({ cols: 62, rows: 38 });
expect(fit).not.toHaveBeenCalled();
expect(resizes).toEqual([]);
});
});
describe('only inline renderers hold, and a hold is released when the session stops qualifying', () => {
const { proto, windowStub } = loadTerminalProto();
windowStub.__codemanKeyboardHoldModes = ['claude'];
const appWith = (session: Record<string, unknown> | null, settings: Record<string, unknown> = {}) => ({
activeSessionId: session ? 's1' : null,
sessions: new Map(session ? [['s1', session]] : []),
loadAppSettingsFromStorage: () => settings,
_keyboardHoldEligible: proto._keyboardHoldEligible,
_releaseKeyboardHold: proto._releaseKeyboardHold,
});
const eligible = (session: Record<string, unknown> | null, settings?: Record<string, unknown>) =>
proto._keyboardHoldEligible.call(appWith(session, settings));
it('holds for an inline renderer (the keyboardHold capability), mode defaulting to claude', () => {
expect(eligible({ mode: 'claude' })).toBe(true);
expect(eligible({})).toBe(true);
});
it('never holds a full-height CLI, fullscreen Claude, a remote or docker pane, or with the setting off', () => {
expect(eligible({ mode: 'codex' })).toBe(false);
expect(eligible({ mode: 'shell' })).toBe(false);
expect(eligible({ mode: 'claude', cliMouseTracking: true })).toBe(false);
expect(eligible({ mode: 'claude', remote: { hostId: 'h' } })).toBe(false);
expect(eligible({ mode: 'claude', docker: { caseName: 'c' } })).toBe(false);
expect(eligible({ mode: 'claude' }, { keyboardHoldsRows: false })).toBe(false);
expect(eligible(null)).toBe(false);
});
it('holds nothing when the server sent no list', () => {
const saved = windowStub.__codemanKeyboardHoldModes;
delete windowStub.__codemanKeyboardHoldModes;
try {
expect(eligible({ mode: 'claude' })).toBe(false);
} finally {
windowStub.__codemanKeyboardHoldModes = saved;
}
});
const reconcile = (session: Record<string, unknown>, opts?: Record<string, unknown>) => {
const app = {
...appWith(session),
_keyboardHeldRows: 38,
_applyKeyboardClip: vi.fn(),
sendResize: vi.fn(() => Promise.resolve(true)),
};
proto._reconcileKeyboardHold.call(app, opts);
return app;
};
it('releases and resizes once the active session no longer qualifies', () => {
const app = reconcile({ mode: 'claude', cliMouseTracking: true });
expect(app._keyboardHeldRows).toBe(0);
expect(app._applyKeyboardClip).toHaveBeenCalled();
expect(app.sendResize).toHaveBeenCalledWith('s1');
});
it('leaves the resize to a caller that does its own (selectSession)', () => {
const app = reconcile({ mode: 'codex' }, { resize: false });
expect(app._keyboardHeldRows).toBe(0);
expect(app.sendResize).not.toHaveBeenCalled();
});
it('keeps a hold the session still qualifies for', () => {
const app = reconcile({ mode: 'claude' });
expect(app._keyboardHeldRows).toBe(38);
expect(app.sendResize).not.toHaveBeenCalled();
});
// session:updated is how a running Claude session reports entering its
// fullscreen renderer (cliMouseTracking), so the shipped handler must release.
const updatedApp = () => ({
...appWith({ mode: 'claude' }),
_keyboardHeldRows: 38,
_applyKeyboardClip: vi.fn(),
sendResize: vi.fn(() => Promise.resolve(true)),
_reconcileKeyboardHold: proto._reconcileKeyboardHold,
renderSessionTabs: vi.fn(),
updateCost: vi.fn(),
updateSubagentParentNames: vi.fn(),
});
it('a session:updated that turns mouse tracking on releases the hold and resizes', () => {
const app = updatedApp();
app.sendResize.mockClear();
proto._onSessionUpdated.call(app, { session: { id: 's1', mode: 'claude', cliMouseTracking: true } });
expect(app._keyboardHeldRows).toBe(0);
expect(app.sendResize).toHaveBeenCalledWith('s1');
});
it('a session:updated for another session, or one that still qualifies, keeps it', () => {
const app = updatedApp();
app.sessions.set('s2', { id: 's2', mode: 'claude' });
proto._onSessionUpdated.call(app, { session: { id: 's2', mode: 'claude', cliMouseTracking: true } });
proto._onSessionUpdated.call(app, { session: { id: 's1', mode: 'claude' } });
expect(app._keyboardHeldRows).toBe(38);
expect(app.sendResize).not.toHaveBeenCalled();
});
});
// The composer must stay on screen with the rows held. The COLUMNS ONLY rule in
// reconcilePtyGeometry exists because a screen taller than its box once hid the
// CLI's input line with nothing able to reach it; the hold makes the screen
// taller on purpose, and this pins that the clip puts the composer back in view.
// A real headless xterm holds Claude's inline frame at the held 38 rows, and the
// shipped getTerminalDimensions and _applyKeyboardClip run against it.
describe('the composer row stays visible with the rows held', () => {
const ROWS = 38;
const COLS = 62;
const CELL = 17;
const BOX = 15 * CELL; // the box left above the keyboard: 15 rows
async function heldApp(transcriptLines: number) {
const { proto, keyboard } = loadTerminalProto();
const term = new HeadlessTerminal({ cols: COLS, rows: ROWS, scrollback: 1000, allowProposedApi: true });
// Claude's inline renderer: the transcript, then its composer between two
// rules with the footer under it, the cursor parked after the prompt glyph.
const rule = '\u2500'.repeat(COLS);
const transcript = Array.from({ length: transcriptLines }, (_, i) => `KBTEST-${String(i + 1).padStart(3, '0')}`);
const frame = [...transcript, rule, '\u276f ', rule, ' ? for shortcuts'].join('\r\n');
await new Promise<void>((done) => term.write(`${frame}\x1b[2A\x1b[3G`, () => done()));
const style = { setProperty: vi.fn(), removeProperty: vi.fn() };
const container = { clientHeight: BOX, style };
const app: Record<string, unknown> = {
_keyboardHeldRows: 0,
fitAddon: { proposeDimensions: () => ({ cols: COLS, rows: 15 }) },
terminal: {
rows: term.rows,
buffer: term.buffer,
element: { parentElement: container },
_core: { _renderService: { dimensions: { css: { cell: { height: CELL } } } } },
},
_localEchoOverlay: { setRowLimit: vi.fn() },
_keyboardHeldRowsActive: proto._keyboardHeldRowsActive,
getTerminalDimensions: proto.getTerminalDimensions,
_applyKeyboardClip: proto._applyKeyboardClip,
};
const buffer = term.buffer.active;
const screenRow = (pattern: RegExp) => {
for (let row = 0; row < ROWS; row++) {
if (pattern.test(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')) return row;
}
return -1;
};
return { app, keyboard, style, buffer, screenRow };
}
/** The --kb-clip offset last written to the container, 0 when removed or never set. */
function clipOf(style: { setProperty: ReturnType<typeof vi.fn>; removeProperty: ReturnType<typeof vi.fn> }) {
const set = style.setProperty.mock.calls.at(-1);
return set ? parseFloat(String(set[1])) : 0;
}
/** Is screen row `row` wholly inside the box once the screen is translated up by `clip` px? */
const fullyVisible = (row: number, clip: number) => row * CELL >= clip && (row + 1) * CELL <= clip + BOX;
it('a long transcript: the composer and the footer under it sit at the bottom of the box', async () => {
const { app, keyboard, style, buffer, screenRow } = await heldApp(100);
keyboard.keyboardVisible = true;
app._keyboardHeldRows = ROWS; // what KeyboardHandler captures on the show transition
// The PTY keeps its rows while the box shrinks to 15; columns still follow the box.
expect((app.getTerminalDimensions as () => unknown).call(app)).toEqual({ cols: COLS, rows: ROWS });
(app._applyKeyboardClip as () => void).call(app);
const composer = screenRow(/^\u276f/);
const footer = screenRow(/\? for shortcuts/);
expect([composer, footer, buffer.cursorY]).toEqual([35, 37, 35]);
const clip = clipOf(style);
expect(clip).toBe(ROWS * CELL - BOX);
expect(fullyVisible(composer, clip)).toBe(true);
expect(fullyVisible(footer, clip)).toBe(true);
// The local-echo overlay may draw down to the last visible row, the composer's included.
const limit = (app._localEchoOverlay as { setRowLimit: ReturnType<typeof vi.fn> }).setRowLimit.mock.calls.at(
-1
)![0];
expect(limit).toBe(ROWS);
});
it('a fresh session whose frame ends near the top: no offset, the composer already in view', async () => {
const { app, keyboard, style, screenRow } = await heldApp(5);
keyboard.keyboardVisible = true;
app._keyboardHeldRows = ROWS;
(app._applyKeyboardClip as () => void).call(app);
const composer = screenRow(/^\u276f/);
expect(composer).toBe(6);
expect(clipOf(style)).toBe(0);
expect(fullyVisible(composer, 0)).toBe(true);
// Rows 0..14 are the visible ones: the overlay must grow upward from row 14,
// not from row 37 under the keyboard.
expect((app._localEchoOverlay as { setRowLimit: ReturnType<typeof vi.fn> }).setRowLimit).toHaveBeenLastCalledWith(
15
);
});
it('keyboard closed: the offset and the overlay bound are both removed', async () => {
const { app, keyboard, style } = await heldApp(100);
keyboard.keyboardVisible = true;
app._keyboardHeldRows = ROWS;
(app._applyKeyboardClip as () => void).call(app);
expect(clipOf(style)).toBeGreaterThan(0);
keyboard.keyboardVisible = false;
const overlay = app._localEchoOverlay as { setRowLimit: ReturnType<typeof vi.fn> };
overlay.setRowLimit.mockClear();
style.setProperty.mockClear();
(app._applyKeyboardClip as () => void).call(app);
expect(style.removeProperty).toHaveBeenCalledWith('--kb-clip');
expect(style.setProperty).not.toHaveBeenCalled();
expect(overlay.setRowLimit).toHaveBeenCalledWith(null);
expect(app._keyboardClipPx).toBe(0);
});
});
type ManualTimers = { setTimeout(fn: () => void): number; clearTimeout(id: number): void; run(): void };
/** setTimeout/clearTimeout whose callbacks run only when the test says so. */
function manualTimers(): ManualTimers {
const pending = new Map<number, () => void>();
let next = 0;
return {
setTimeout(fn) {
pending.set(++next, fn);
return next;
},
clearTimeout(id) {
pending.delete(id);
},
run() {
for (let round = 0; pending.size && round < 10; round++) {
for (const [id, fn] of [...pending]) {
pending.delete(id);
fn();
}
}
},
};
}
function loadKeyboardHandler(enabled: boolean, opts: { app?: Record<string, unknown>; timers?: ManualTimers } = {}) {
const viewport = { width: 390, height: 664, offsetTop: 0, addEventListener: () => {}, removeEventListener: () => {} };
const layout = { height: 664 };
const app = opts.app ?? {
terminal: { rows: 38 },
_keyboardHeldRows: 0,
_keyboardHoldEligible: () => enabled,
_applyKeyboardClip: vi.fn(),
// The shipped release, so a hide or a shape change goes through the real helper.
_releaseKeyboardHold: loadTerminalProto().proto._releaseKeyboardHold,
relayoutMobileSubagentWindows: () => {},
};
const context = vm.createContext({
console,
app,
navigator: { userAgent: 'iPhone', maxTouchPoints: 5 },
window: {
get innerWidth() {
return viewport.width;
},
get innerHeight() {
return layout.height;
},
visualViewport: viewport,
addEventListener: () => {},
removeEventListener: () => {},
matchMedia: () => ({ matches: true }),
scrollTo: () => {},
},
document: {
body: { classList: { add: () => {}, remove: () => {} } },
documentElement: { style: { setProperty: () => {} } },
addEventListener: () => {},
removeEventListener: () => {},
getElementById: () => null,
querySelector: () => null,
},
setTimeout: opts.timers ? opts.timers.setTimeout : () => 1,
clearTimeout: opts.timers ? opts.timers.clearTimeout : () => {},
});
vm.runInContext(`${read('mobile-handlers.js')}\nglobalThis.__KH = KeyboardHandler;`, context);
const handler = (context as unknown as { __KH: { init(): void; handleViewportResize(): void } }).__KH;
handler.init();
const resizeTo = (width: number, height: number, layoutHeight = height) => {
viewport.width = width;
viewport.height = height;
layout.height = layoutHeight;
handler.handleViewportResize();
};
return { app: app as typeof app & Record<string, any>, handler, resizeTo };
}
describe('KeyboardHandler captures and releases the hold', () => {
it('captures the keyboard-free rows on show and releases them on hide', () => {
const { app, resizeTo } = loadKeyboardHandler(true);
resizeTo(390, 364, 664);
expect(app._keyboardHeldRows).toBe(38);
app._applyKeyboardClip.mockClear();
resizeTo(390, 664);
expect(app._keyboardHeldRows).toBe(0);
expect(app._applyKeyboardClip).toHaveBeenCalledTimes(1);
});
it('holds nothing for a session that does not qualify (setting off, a full-height CLI)', () => {
const { app, resizeTo } = loadKeyboardHandler(false);
resizeTo(390, 364, 664);
expect(app._keyboardHeldRows).toBe(0);
});
it('releases on a shape change with the keyboard still up', () => {
const { app, resizeTo } = loadKeyboardHandler(true);
resizeTo(390, 364, 664);
resizeTo(664, 90, 390); // rotated, keyboard still up
expect(app._keyboardHeldRows).toBe(0);
});
});
// A rotation (or fold) with the keyboard still up releases the hold, since its
// rows belong to the display that is gone. The keyboard is still visible, so
// throttledResize stands aside and only the viewport settle can refit: the
// release has to arm it, or xterm keeps the held rows with no clip and the
// composer sits under the keyboard until it closes. The real KeyboardHandler
// drives the real terminal-ui methods over a headless xterm, with the timers
// run by hand so the opening settle finishes before the rotation.
describe('a rotation with the keyboard up', () => {
const CELL = 17;
async function rotationSetup(eligible: boolean) {
const box = { cols: 44, rows: 38 };
const term = new HeadlessTerminal({ cols: box.cols, rows: box.rows, scrollback: 1000, allowProposedApi: true });
const rule = '\u2500'.repeat(40);
const transcript = Array.from({ length: 100 }, (_, i) => `KBTEST-${String(i + 1).padStart(3, '0')}`);
const frame = [...transcript, rule, '\u276f ', rule, ' ? for shortcuts'].join('\r\n');
await new Promise<void>((done) => term.write(`${frame}\x1b[2A\x1b[3G`, () => done()));
const style = { setProperty: vi.fn(), removeProperty: vi.fn() };
const container = {
get clientHeight() {
return box.rows * CELL;
},
style,
};
const sent: unknown[] = [];
const app: Record<string, any> = {
activeSessionId: 's1',
sessions: new Map([['s1', { id: 's1', mode: eligible ? 'claude' : 'codex' }]]),
_keyboardHeldRows: 0,
loadAppSettingsFromStorage: () => ({}),
relayoutMobileSubagentWindows: () => {},
isTerminalAtBottom: () => true,
_scheduleOverflowAffordanceSync: () => {},
_syncMobileHelperTextareaToCursor: () => {},
fitAddon: { proposeDimensions: () => ({ ...box }), fit: () => term.resize(box.cols, box.rows) },
terminal: {
get rows() {
return term.rows;
},
get cols() {
return term.cols;
},
get buffer() {
return term.buffer;
},
resize: (cols: number, rows: number) => term.resize(cols, rows),
scrollToBottom: () => {},
element: { parentElement: container },
_core: { _renderService: { dimensions: { css: { cell: { height: CELL } } } } },
},
sendResize: vi.fn(() => {
sent.push(app.getTerminalDimensions());
return Promise.resolve(true);
}),
};
const timers = manualTimers();
const { handler, resizeTo } = loadKeyboardHandler(eligible, { app, timers });
const { proto, windowStub } = loadTerminalProto(handler as unknown as Record<string, unknown>);
windowStub.__codemanKeyboardHoldModes = ['claude'];
// Every method not stubbed above is the shipped one.
Object.setPrototypeOf(app, proto);
const buffer = term.buffer.active;
const composerRow = () => {
for (let row = 0; row < term.rows; row++) {
if (/^\u276f/.test(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')) return row;
}
return -1;
};
// Keyboard up over a portrait display, and the opening settle runs to the end.
box.rows = 15;
resizeTo(390, 364, 664);
timers.run();
return { app, box, term, style, sent, timers, resizeTo, composerRow };
}
it('refits to the new display and tells the PTY, so the composer is back in the box', async () => {
const { app, box, term, style, sent, timers, resizeTo, composerRow } = await rotationSetup(true);
// The hold is in place after the opening settle: 38 rows in a 15-row box, clipped.
expect([term.cols, term.rows, app._keyboardHeldRows]).toEqual([44, 38, 38]);
expect(app._keyboardClipPx).toBe((38 - 15) * CELL);
// Rotate to landscape with the keyboard still up: a 12-row, 76-column box.
box.cols = 76;
box.rows = 12;
app.sendResize.mockClear();
sent.length = 0;
style.removeProperty.mockClear();
resizeTo(664, 120, 390);
timers.run();
expect(app._keyboardHeldRows).toBe(0);
expect([term.cols, term.rows]).toEqual([76, 12]);
expect(app.sendResize).toHaveBeenCalledWith('s1');
expect(sent).toEqual([{ cols: 76, rows: 12 }]);
// No clip left, and with the rows fitted to the box the composer is inside it.
expect(style.removeProperty).toHaveBeenCalledWith('--kb-clip');
expect(app._keyboardClipPx).toBe(0);
// Shrinking the rows, xterm keeps the cursor row (the composer) on the last
// screen row; Claude redraws its footer under it after the SIGWINCH.
const composer = composerRow();
expect(composer).toBe(11);
expect((composer + 1) * CELL).toBeLessThanOrEqual(box.rows * CELL);
});
it('with no hold, a rotation arms nothing, as before', async () => {
const { app, box, term, timers, resizeTo } = await rotationSetup(false);
// Not eligible: the opening settle fitted the short box, as before the hold existed.
expect([term.rows, app._keyboardHeldRows]).toEqual([15, 0]);
box.cols = 76;
box.rows = 12;
app.sendResize.mockClear();
resizeTo(664, 120, 390);
timers.run();
expect(app.sendResize).not.toHaveBeenCalled();
expect([term.cols, term.rows]).toEqual([44, 15]);
});
});
describe('keyboardHoldsRows is a per-device setting', () => {
const settingsUi = read('settings-ui.js');
const html = read('index.html');
const schemas = readFileSync(resolve(import.meta.dirname, '../src/web/schemas.ts'), 'utf8');
it('is a display key, stripped from the PUT and absent from the strict schema', () => {
const start = settingsUi.indexOf('const displayKeys = new Set([');
expect(settingsUi.slice(start, settingsUi.indexOf('])', start))).toContain("'keyboardHoldsRows'");
expect(settingsUi).toContain('keyboardHoldsRows: _khr,');
expect(schemas).not.toContain('keyboardHoldsRows');
});
it('defaults ON and loads/saves by id', () => {
expect(html).toContain('id="appSettingsKeyboardHoldsRows"');
expect(settingsUi).toContain(
"document.getElementById('appSettingsKeyboardHoldsRows').checked = settings.keyboardHoldsRows !== false;"
);
expect(settingsUi).toContain("keyboardHoldsRows: document.getElementById('appSettingsKeyboardHoldsRows').checked,");
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* @fileoverview The "Keep Size Under Keyboard" App Settings row (#672) in Chinese (zh-CN):
* its label and description run through the real translator over the real index.html in
* JSDOM, read in Chinese with no English left beyond `Claude` and `Codex`, and each key is
* in the dictionary once (a repeated key in the object literal silently replaces the first).
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
type Api = { configure(o: Record<string, string>): void; start(): void };
/** What may stay Latin: the two CLI names. */
const leftover = (text: string) => text.replace(/\b(Claude|Codex)\b/g, '').match(/[A-Za-z]+/g) ?? [];
function rowStrings(doc: Document): { label: string; description: string } {
const row = doc.getElementById('appSettingsKeyboardHoldsRows')!.closest('.set-row')!;
return {
label: row.querySelector('.set-row-label')!.textContent!.trim(),
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
};
}
const source = new JSDOM(INDEX);
const english = rowStrings(source.window.document);
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
const chinese = rowStrings(dom.window.document);
afterAll(() => {
source.window.close();
dom.window.close();
});
describe('the Keep Size Under Keyboard row in zh-CN', () => {
it('finds the row (the check is not vacuous)', () => {
expect(english.label).toBe('Keep Size Under Keyboard');
expect(english.description).toMatch(/^For local Claude sessions, opening the on-screen keyboard/);
});
it('translates the label and the description, with no English left', () => {
expect(chinese).toEqual({
label: '键盘弹出时保持尺寸',
description:
'对于本地 Claude 会话,打开屏幕键盘时会保持会话尺寸并改为滚动视图,这样 Claude 不会重绘屏幕而丢失对话记录行。全屏界面(Codex、Claude 的全屏模式)仍像以前一样调整尺寸。',
});
for (const key of ['label', 'description'] as const) expect(leftover(chinese[key])).toEqual([]);
});
it('adds each key to the dictionary once', () => {
for (const text of [english.label, english.description]) {
expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1);
}
});
});
+93
View File
@@ -323,6 +323,99 @@ describe('Virtual Keyboard', () => {
expect(mainPadding).toBe('');
});
it('holds the PTY rows under the keyboard and keeps the Claude composer inside the visible box', async () => {
// #672: a keyboard-driven height change makes Claude's inline renderer reprint
// only the tail that fits and lose the rest, so the rows are held instead, and
// --kb-clip has to keep the composer in view of a screen now taller than its box.
const modes = await page.evaluate(() => (window as any).__codemanKeyboardHoldModes);
expect(modes).toEqual(['claude']);
const rowsBefore = await page.evaluate(async () => {
(window as any).__resizes = [];
app.activeSessionId = 'mobile-keyboard-hold-test';
app.sessions.set('mobile-keyboard-hold-test', {
id: 'mobile-keyboard-hold-test',
mode: 'claude',
status: 'running',
});
app.hideWelcome();
// Fit the terminal to its box first, as a selected session is: these are
// the keyboard-free rows the hold must keep.
app.syncTerminalGeometry();
app.sendResize = (id: string) => {
(window as any).__resizes.push({ id, ...app.getTerminalDimensions() });
return Promise.resolve(true);
};
app.terminal.reset();
const cols = app.terminal.cols;
const rule = '\u2500'.repeat(cols);
const transcript = Array.from({ length: 100 }, (_, i) => `KBTEST-${String(i + 1).padStart(3, '0')}`);
const frame = [...transcript, rule, '\u276f ', rule, ' ? for shortcuts'].join('\r\n');
await new Promise<void>((resolve) => app.terminal.write(`${frame}\x1b[2A\x1b[3G`, resolve));
return app.terminal.rows;
});
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(800);
const held = await page.evaluate(() => {
const term = app.terminal;
const buffer = term.buffer.active;
const rowOf = (pattern: RegExp) => {
for (let row = 0; row < term.rows; row++) {
if (pattern.test(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')) return row;
}
return -1;
};
const container = document.getElementById('terminalContainer') as HTMLElement;
const box = container.getBoundingClientRect();
const screen = (container.querySelector('.xterm-screen') as HTMLElement).getBoundingClientRect();
const cell = term._core._renderService.dimensions.css.cell.height;
const composer = rowOf(/^\u276f/);
const footer = rowOf(/\? for shortcuts/);
return {
rows: term.rows,
heldRows: app._keyboardHeldRows,
clip: container.style.getPropertyValue('--kb-clip'),
composer,
footer,
// Where the rows land on the page with the clip applied.
composerTop: screen.top + composer * cell,
footerBottom: screen.top + (footer + 1) * cell,
// Where they would land without it: below the box, i.e. under the keyboard.
footerBottomUnclipped: box.top + (footer + 1) * cell,
boxTop: box.top,
boxBottom: box.bottom,
viewportBottom:
(window.visualViewport?.offsetTop ?? 0) + (window.visualViewport?.height ?? window.innerHeight),
resizes: (window as any).__resizes,
};
});
expect(held.rows).toBe(rowsBefore);
expect(held.heldRows).toBe(rowsBefore);
// Every resize sent while the keyboard was up carried the held rows.
for (const r of held.resizes) expect(r.rows).toBe(rowsBefore);
expect([held.composer, held.footer]).toEqual([rowsBefore - 3, rowsBefore - 1]);
// Not vacuous: unclipped, the footer would sit below the box.
expect(held.footerBottomUnclipped).toBeGreaterThan(held.boxBottom + 1);
expect(parseFloat(held.clip)).toBeGreaterThan(0);
expect(held.composerTop).toBeGreaterThanOrEqual(held.boxTop - 1);
expect(held.footerBottom).toBeLessThanOrEqual(held.boxBottom + 1);
expect(held.boxBottom).toBeLessThanOrEqual(held.viewportBottom + 1);
await hideKeyboard(page);
await page.waitForTimeout(800);
// Released: no hold, no offset, and the rows follow the box again (the box
// after a keyboard cycle need not be the one before it, so compare with the fit).
const released = await page.evaluate(() => ({
heldRows: app._keyboardHeldRows,
clip: (document.getElementById('terminalContainer') as HTMLElement).style.getPropertyValue('--kb-clip'),
rows: app.terminal.rows,
fits: window.CodemanTerminalGeometry.clampTerminalDimensions(app.fitAddon.proposeDimensions()).rows,
}));
expect(released).toEqual({ heldRows: 0, clip: '', rows: released.fits, fits: released.fits });
});
it('coalesces keyboard animation frames into one final terminal fit', async () => {
const result = await page.evaluate(async () => {
// `app.terminal` and `app.fitAddon` are only assigned by initTerminal(),
+10
View File
@@ -181,6 +181,16 @@ describe('WebServer.renderIndexHtml', () => {
}
});
it('injects the keyboard-hold modes from the keyboardHold capability: inline Claude, never full-height codex', async () => {
for (const solo of [undefined, 'sess-123']) {
const { server } = makeServer();
const html = await render(server, solo);
const match = html.match(/window\.__codemanKeyboardHoldModes=(\[[^<]*\]);/);
expect(match).not.toBeNull();
expect(JSON.parse(match![1])).toEqual(['claude']);
}
});
it('escapes the solo id so it cannot break out of the inline <script>', async () => {
const { server } = makeServer({});
const html = await render(server, 'a</script><b>');
+3 -1
View File
@@ -115,7 +115,9 @@ describe('WebServer index.html <title> templating (#82)', () => {
// enabled CLI declares a gutter, so it needs stripping on every machine.
.replace(/<script>window\.__codemanTranscriptGutter=\{.*?\};<\/script>\n/, '')
// Likewise the page-key mode list: an array, possibly empty, always injected.
.replace(/<script>window\.__codemanTranscriptPageKeys=\[.*?\];<\/script>\n/, '');
.replace(/<script>window\.__codemanTranscriptPageKeys=\[.*?\];<\/script>\n/, '')
// And the keyboard-hold mode list, built the same way.
.replace(/<script>window\.__codemanKeyboardHoldModes=\[.*?\];<\/script>\n/, '');
const beforeTitle = rawTemplate.split('<title>Codeman</title>')[0];
const afterTitle = rawTemplate.split('<title>Codeman</title>')[1];
expect(html.startsWith(beforeTitle)).toBe(true);