Merge pull request #711: fix(mobile): hold the PTY rows while the on-screen keyboard is up

This commit is contained in:
Codeman maintainer committed 2026-10-27 02:24:17 +01:00
commit 98526527c8
26 files changed
+1227 -15

No files matched your search

+24
View File
@@ -548,6 +548,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);