// 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) => { 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): number; keyboardVisibleRowLimit(clipPx: number, viewHeight: number, cellHeight: number): number; }; function loadGeometry(): Geometry { const window: Record = {}; 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, ...args: unknown[]) => unknown>; function loadTerminalProto(keyboard: Record = { keyboardVisible: true }) { const windowStub: Record = { 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 | null, settings: Record = {}) => ({ activeSessionId: session ? 's1' : null, sessions: new Map(session ? [['s1', session]] : []), loadAppSettingsFromStorage: () => settings, _keyboardHoldEligible: proto._keyboardHoldEligible, _releaseKeyboardHold: proto._releaseKeyboardHold, }); const eligible = (session: Record | null, settings?: Record) => 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, opts?: Record) => { 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((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 = { _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; removeProperty: ReturnType }) { 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 }).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 }).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 }; 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 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; 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, 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((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 = { 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); 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,"); }); });