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

+3
View File
@@ -301,6 +301,9 @@ const capabilitiesSchema = z
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
// Optional: absent means no paging, the safe default for an unmeasured CLI.
transcriptPageKeys: z.boolean().optional(),
// Keep the PTY's rows while a phone keyboard is up (inline renderers only).
// Optional: absent means the keyboard resizes, the safe default.
keyboardHold: z.literal('inline').optional(),
keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']),
+4
View File
@@ -289,6 +289,10 @@ const CLAUDE: CliEntry = {
// forwarded (older CLI, inline renderer, the local-scrollback opt-out) a scroll is
// turned into PageUp/PageDown, which Claude pages its transcript on (#205).
transcriptPageKeys: true,
// Claude's default renderer is inline, and each keyboard-driven PTY height change made
// it reprint only the tail that fits, erasing transcript rows from tmux (measured on
// 2.1.306: 25 of 100 lines over five keyboard cycles). See CliCapabilities.keyboardHold.
keyboardHold: 'inline',
keyboardAccessory: 'agent',
privilegedCommandGate: false,
startMode: 'interactive',
+24
View File
@@ -563,6 +563,30 @@ export interface CliCapabilities {
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
*/
transcriptPageKeys?: boolean;
/**
* `'inline'`: the CLI renders inline (its content stacks at the bottom of the
* main screen), so while a phone's on-screen keyboard is up the browser keeps
* the PTY's rows and scrolls the view instead of resizing (`keyboardHoldsRows`,
* terminal-ui.js `_keyboardHoldEligible`). Read by the frontend through
* `window.__codemanKeyboardHoldModes`, which the server builds from this field.
*
* Why it exists: every PTY height change makes Claude Code's inline renderer
* clear the screen and reprint only the tail that fits (#672). Measured on
* Claude Code 2.1.306 with tmux 3.2a, a fresh session already at the phone's
* size printing 100 numbered lines: five keyboard open/close cycles (PTY
* 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in
* `tmux capture-pane` history, the other 25 gone for good; with the rows held
* the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained.
*
* ⚠ Only for a renderer that is inline. A CLI that lays out the FULL height
* (codex: banner at the top, composer pinned to the bottom) cannot fit both
* ends above the keyboard and must be resized instead, so it stays absent.
* Absent means resize, the safe default for an unmeasured CLI. Even when set,
* the hold stands aside while the CLI reports mouse tracking (Claude's
* fullscreen mode, which lays out the full height and has not been measured
* losing lines, so it resizes as before), and for remote and docker sessions.
*/
keyboardHold?: 'inline';
keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean;
+2
View File
@@ -66,6 +66,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge**: ⚠️ in `handleViewportResize()`, a visual-viewport resize that changes the WIDTH is a shape change (rotation, fold) and must never be read as the keyboard; it re-baselines instead, or `keyboardVisible` latches with no keyboard. ⚠️ `init()` must seed `lastViewportWidth`. ⚠️ With the keyboard up, a shape change baselines to `window.innerHeight`, never the shrunk visual height. ⚠️ The hinge is reserved via `--fold-inline-end`/`--fold-block-end` (0px when unfolded): each overlay fold rule must re-state its own gutter, a base gutter overridden by a later `@media` block needs its own fold restatement there on a zero base, and dialogs use physical sides (left/top segment) in every language. Guard: `test/foldable-layout.test.ts`. → [architecture-invariants#folding-devices](../../docs/architecture-invariants.md#folding-devices)
**Rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): while the keyboard is up, an eligible session keeps its PTY rows (`app._keyboardHeldRows`, captured by `KeyboardHandler` on the show transition and read through `getTerminalDimensions()`) and `.xterm-screen` is translated up by `--kb-clip` (`_applyKeyboardClip()`, `keyboardClipPx()`), with the local-echo overlay bounded through `setRowLimit()`. ⚠️ Eligibility is `_keyboardHoldEligible()` only (the published `window.__codemanKeyboardHoldModes`, never an id; never with `cliMouseTracking`, remote or docker). ⚠️ Every path that can change eligibility calls `_reconcileKeyboardHold()` (tab switch, `session:updated`, reconnect, settings save), keyboard hide and a shape change release it (a shape change also arms the viewport settle, the only refit while the keyboard is up), and nothing re-holds with the keyboard already up. → [architecture-invariants#pty-and-browser-terminal-geometry](../../docs/architecture-invariants.md#pty-and-browser-terminal-geometry)
**WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](../../docs/architecture-invariants.md#webgl-renderer-toggle)
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
+7
View File
@@ -2452,6 +2452,8 @@ class CodemanApp {
if (session.id === this.activeSessionId && session.tokens) {
this.updateRespawnTokens(session.tokens);
}
// cliMouseTracking (Claude's fullscreen mode) ends a keyboard row hold.
if (session.id === this.activeSessionId) this._reconcileKeyboardHold?.();
// Update parentSessionName for any subagents belonging to this session
// (fixes stale name display after session rename)
this.updateSubagentParentNames(session.id);
@@ -5166,6 +5168,8 @@ class CodemanApp {
// backoff reconnect, but if it is not on this session (dead socket, or
// a give-up) nothing else would re-establish it from here.
if (this._wsSessionId !== restoreId) this._connectWs(restoreId);
// The fresh session record may no longer qualify for a keyboard row hold.
this._reconcileKeyboardHold?.();
void this._onSessionNeedsRefresh({ id: restoreId });
// The split pane's second terminal reconnects on its own backoff (up to
// 10 s between tries); the server is back now, so skip the wait. A no-op
@@ -9703,6 +9707,9 @@ class CodemanApp {
this._cleanupPreviousSession(sessionId);
this.activeSessionId = sessionId;
this._activateFileBrowserSession?.(sessionId);
// A keyboard row hold belongs to the session it was taken for: release it here
// if this one resizes instead (the geometry work below then fits it).
this._reconcileKeyboardHold?.({ resize: false });
// Repaint the partial-history banner for the tab being switched TO. The
// replay paths refresh it when their fetch lands; without this the previous
// session's notice stays on screen until then (#258). The switch lands at
+61 -1
View File
@@ -3349,7 +3349,9 @@ function clampTerminalDimensions(proposed) {
* could reach it. Output visible, typing invisible, for as long as the claim
* stayed hot. Width is the axis the wrap arithmetic depends on; rows only
* decide how much is on screen at once, and keeping the local row count keeps
* the composer at the bottom of a viewport that scrolls.
* the composer at the bottom of a viewport that scrolls. The one deliberate
* screen taller than its box is this device's own rows held under the
* on-screen keyboard, which keyboardClipPx keeps the composer in view for.
*
* @param {{cols: number, rows: number}|null} local - what xterm currently holds
* @param {{cols: number, rows: number}|null} pty - what the server just reported
@@ -3361,6 +3363,62 @@ function reconcilePtyGeometry(local, pty) {
return { adopt: true, cols: pty.cols };
}
/** Rows below the last content within which the cursor still counts (keyboardClipPx). */
const KEYBOARD_CLIP_CURSOR_REACH = 2;
/**
* How far to scroll the terminal SCREEN up while its rows are held under the
* on-screen keyboard (setting `keyboardHoldsRows`).
*
* Every PTY height change makes Claude Code clear the screen and reprint only
* the transcript tail that fits, so rows above that tail are lost from tmux and
* older ones stay in history as stale copies. The keyboard opening and closing
* was such a change each time. Instead the rows stay as they were and
* `.xterm-screen` (taller than its box now) is translated up by this many px.
*
* This is the one deliberate exception to "a screen taller than its container
* hides the input line" (see reconcilePtyGeometry): the offset below is what
* keeps the live content in view.
*
* - Following live output: the last row with content sits at the bottom of the
* box. A fresh shell prompting on row 1 shows the top; Claude's footer under
* its composer stays visible. The cursor counts only when it is right below
* that content (a prompt or composer line still empty): a TUI that parks its
* cursor far below what it drew (codex's dialogs, with the cursor on the last
* row of an otherwise blank screen) must not scroll its content out of view.
* - Reading history: the offset follows the scroll position, so the top of the
* scrollback is reachable and scrolling stays continuous.
*
* ponytail: recomputed on every render with no hysteresis; add some if a TUI's
* mid-redraw frames ever make the view jitter.
*
* @param {{rows: number, cellHeight: number, viewHeight: number, cursorRow: number,
* lastContentRow: number, following: boolean, viewportY: number, baseY: number}} input
* lastContentRow: the last non-blank screen row, or -1 when the screen is blank
* @returns {number} px, between 0 and the screen's overflow
*/
function keyboardClipPx(input) {
const { rows, cellHeight, viewHeight, cursorRow, lastContentRow, following, viewportY, baseY } = input;
if (!(cellHeight > 0) || !(viewHeight > 0) || !(rows > 0)) return 0;
const max = Math.max(0, rows * cellHeight - viewHeight);
if (max === 0) return 0;
if (!following && baseY > 0) return Math.round(max * Math.min(1, Math.max(0, viewportY / baseY)));
const cursorCounts = lastContentRow < 0 || cursorRow <= lastContentRow + KEYBOARD_CLIP_CURSOR_REACH;
const contentBottomRow = cursorCounts ? Math.max(cursorRow, lastContentRow) : lastContentRow;
const bottom = (Math.min(rows - 1, Math.max(0, contentBottomRow)) + 1) * cellHeight;
return Math.round(Math.min(max, Math.max(0, bottom - viewHeight)));
}
/**
* How many screen rows, from the top, are fully visible at a given offset: the
* local-echo overlay grows upward from the last of them (`setRowLimit`).
* @returns {number}
*/
function keyboardVisibleRowLimit(clipPx, viewHeight, cellHeight) {
if (!(cellHeight > 0)) return 0;
return Math.max(1, Math.floor((clipPx + viewHeight) / cellHeight));
}
/**
* Which session does a dashboard URL's fragment ask for? Another page that
* holds the dashboard's window, such as a task board, points it at
@@ -3491,6 +3549,8 @@ if (typeof window !== 'undefined') {
window.CodemanTerminalGeometry = {
clampTerminalDimensions,
reconcilePtyGeometry,
keyboardClipPx,
keyboardVisibleRowLimit,
TERMINAL_MIN_COLS,
TERMINAL_MIN_ROWS,
};
+3
View File
@@ -667,6 +667,9 @@
'Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.':
'终端粗体文本的字重。仅当字体提供比其粗体更重的字形时才有意义。',
'Local Echo': '本地回显',
'Keep Size Under Keyboard': '键盘弹出时保持尺寸',
"For local Claude sessions, opening the on-screen keyboard keeps the session's size and scrolls the view instead, so Claude does not reprint its screen and lose transcript lines. Full-screen interfaces (Codex, Claude's fullscreen mode) are resized as before.":
'对于本地 Claude 会话,打开屏幕键盘时会保持会话尺寸并改为滚动视图,这样 Claude 不会重绘屏幕而丢失对话记录行。全屏界面(Codex、Claude 的全屏模式)仍像以前一样调整尺寸。',
'CJK Input': '中日韩输入',
'Extended Keyboard Bar': '扩展键盘栏',
'Gesture Control (beta)': '手势控制(测试版)',
+7
View File
@@ -1944,6 +1944,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLocalEcho"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="keyboard hold rows resize redraw mobile">
<div class="set-row-text">
<span class="set-row-label">Keep Size Under Keyboard</span>
<span class="set-row-desc">For local Claude sessions, opening the on-screen keyboard keeps the session's size and scrolls the view instead, so Claude does not reprint its screen and lose transcript lines. Full-screen interfaces (Codex, Claude's fullscreen mode) are resized as before.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsKeyboardHoldsRows"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="cjk input ime chinese japanese korean">
<div class="set-row-text">
<span class="set-row-label">CJK Input</span>
+22
View File
@@ -344,6 +344,13 @@ const KeyboardHandler = {
// branches are skipped on a shape change, whichever way the height moved.
if (!shapeChanged && heightDiff > 150 && !this.keyboardVisible) {
this.keyboardVisible = true;
// Hold the PTY's rows for as long as the keyboard covers the terminal
// (keyboardHoldsRows, keyboardClipPx in constants.js): every height
// change makes Claude Code clear and reprint only the tail that fits.
// Captured before anything refits, so these are the keyboard-free rows.
if (typeof app !== 'undefined' && app.terminal && app._keyboardHoldEligible?.()) {
app._keyboardHeldRows = app.terminal.rows;
}
document.body.classList.add('keyboard-visible');
// While the keyboard is open, size the app to the visual viewport so
// xterm's bottom row and cursor sit above the OS keyboard.
@@ -355,6 +362,7 @@ const KeyboardHandler = {
// iOS 26's persistent 24px discrepancy, and Safari bottom bar changes
else if (!shapeChanged && heightDiff < 100 && this.keyboardVisible) {
this.keyboardVisible = false;
this._releaseHeldRows();
document.body.classList.remove('keyboard-visible');
this.onKeyboardHide();
// Re-sync --app-height now that keyboard is gone (MobileDetection skipped
@@ -381,6 +389,12 @@ const KeyboardHandler = {
// drop can re-arm the show branch against a baseline that already sits
// at the shrunk height.
if (shapeChanged) {
// Held rows belong to the display that is gone: release them and arm the
// settle, which refits and tells the PTY. Nothing else would: with the
// keyboard up throttledResize stands aside, and a rotation alone arms no
// settle, so xterm kept the held rows with no clip and the composer sat
// under the keyboard until it closed.
if (this._releaseHeldRows()) this._scheduleViewportSettle();
this.initialViewportHeight = this.keyboardVisible ? window.innerHeight : currentHeight;
} else if (!this.keyboardVisible) {
this.initialViewportHeight = currentHeight;
@@ -394,6 +408,11 @@ const KeyboardHandler = {
this.lastViewportHeight = currentHeight;
},
/** End a keyboard row hold (app._releaseKeyboardHold); true when one was held. */
_releaseHeldRows() {
return typeof app !== 'undefined' && !!app._releaseKeyboardHold?.();
},
/** Update layout when keyboard shows/hides */
updateLayoutForKeyboard() {
if (!window.visualViewport) return;
@@ -606,6 +625,9 @@ const KeyboardHandler = {
// geometry xterm will actually keep (issue #464).
app.syncTerminalGeometry?.();
if (this.keyboardVisible) this._shrinkPaddingToFit();
// Rows held: nothing re-renders on its own (the size did not change),
// so place the screen for the settled box here.
app._applyKeyboardClip?.();
// Following live output → bottom, as before. Reading history → back to
// the pre-reflow anchor instead of being yanked down (#259).
if (shouldRestoreScroll) {
+9 -1
View File
@@ -618,6 +618,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
// Default ON, so an absent key reads as enabled rather than as off.
document.getElementById('appSettingsKeyboardHoldsRows').checked = settings.keyboardHoldsRows !== false;
// Auto Copy (copy-on-select): per-device, default OFF everywhere. It quietly
// overwrites the system clipboard on a gesture the user may have meant only as
// a way to read, so it is opt-in rather than a default anyone has to discover.
@@ -2958,6 +2960,7 @@ Object.assign(CodemanApp.prototype, {
Math.min(24, Math.max(0.5, parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2)),
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
keyboardHoldsRows: document.getElementById('appSettingsKeyboardHoldsRows').checked,
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
copyStripMargin: document.getElementById('appSettingsCopyStripMargin').checked,
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
@@ -3050,6 +3053,8 @@ Object.assign(CodemanApp.prototype, {
// Save to localStorage
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
// Turning Keep Size Under Keyboard off while it holds releases it now.
this._reconcileKeyboardHold?.();
this.applyTerminalFontFamily?.(settings.terminalFontFamily);
this.applyTerminalFontWeights?.(settings);
@@ -3202,6 +3207,9 @@ Object.assign(CodemanApp.prototype, {
const _chipFlip = this.planUsageCollectionFlip(_prev, settings.showPlanUsageLimits);
const {
localEchoEnabled: _leo,
// Per-device (it is about this screen's keyboard) and absent from
// SettingsUpdateSchema (.strict()), so sending it would 400 the whole PUT.
keyboardHoldsRows: _khr,
cjkInputEnabled: _cjk,
extendedKeyboardBar: _ekb,
skin: _skin,
@@ -4896,7 +4904,7 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'showGpuStats', 'showCpuMhz', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'keyboardHoldsRows', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
+9
View File
@@ -23195,3 +23195,12 @@ html[data-header-stats='tiles'] .header-plan-usage:not(:has(.pu-row)) {
border: 1px solid var(--control-border);
color: var(--text-muted);
}
/* Rows held under the on-screen keyboard (keyboardHoldsRows; keyboardClipPx in
constants.js): the screen stays taller than its box and is scrolled up by
--kb-clip, set on #terminalContainer by _applyKeyboardClip (terminal-ui.js).
The helper textarea, IME preview, link layer and both echo overlays live
inside .xterm-screen, so they move with it, and every hit test reads rects. */
body.keyboard-visible .terminal-container .xterm-screen {
transform: translateY(calc(-1 * var(--kb-clip, 0px)));
}
+123 -4
View File
@@ -1060,6 +1060,9 @@ Object.assign(CodemanApp.prototype, {
if (MobileDetection.isTouchDevice()) {
this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor());
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
// Keep the live content above the keyboard while the rows are held.
this.terminal.onCursorMove(() => this._applyKeyboardClip());
this.terminal.onRender(() => this._applyKeyboardClip());
}
// Renderer liveness — see _startRenderLivenessWatchdog. Registered for every
@@ -6556,12 +6559,124 @@ Object.assign(CodemanApp.prototype, {
// timer and the resize observer, where an exception takes the rest of the
// callback (the padding fit, the scroll restore, the SIGWINCH) with it.
try {
return window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions());
const dims = window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions());
// Rows held under the on-screen keyboard (see keyboardClipPx): the
// container shrank, the PTY must not. Columns still follow the container.
const held = this._keyboardHeldRowsActive?.();
if (dims && held) dims.rows = Math.max(dims.rows, held);
return dims;
} catch {
return null;
}
},
/**
* Rows held while the on-screen keyboard is up, or 0. Set by KeyboardHandler
* on the show transition when `keyboardHoldsRows` is on (default), cleared on
* hide and on a shape change. Every geometry read goes through
* getTerminalDimensions(), so holding it there holds the fit, the PTY resize
* and every capture-geometry comparison alike.
* @returns {number}
*/
_keyboardHeldRowsActive() {
if (!(this._keyboardHeldRows > 0)) return 0;
return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible ? this._keyboardHeldRows : 0;
},
/**
* May the keyboard hold this session's rows? Only an INLINE renderer (the
* `keyboardHold` capability, read from `window.__codemanKeyboardHoldModes`,
* never an id literal here): a CLI that lays out the full height (codex) cannot
* show both its content and its composer above the keyboard and must be
* resized, as before. Never while the CLI reports mouse tracking (Claude's
* fullscreen mode), and never for a remote or docker session, whose pane hosts
* a nested client. Absent data means no hold. Per-device setting
* `keyboardHoldsRows`, default ON.
*/
_keyboardHoldEligible(sessionId = this.activeSessionId) {
if (this.loadAppSettingsFromStorage?.()?.keyboardHoldsRows === false) return false;
const session = sessionId ? this.sessions?.get(sessionId) : null;
if (!session || session.remote || session.docker || session.cliMouseTracking) return false;
const modes = window.__codemanKeyboardHoldModes;
return Array.isArray(modes) && modes.includes(session.mode || 'claude');
},
/**
* Drop a hold the active session no longer qualifies for (switched to a CLI
* that resizes, entered fullscreen, setting turned off) and, unless the caller
* resizes itself (selectSession), fit and tell the PTY at once, which is the
* old keyboard behavior. Never re-holds: a session that becomes eligible with
* the keyboard already up keeps the short rows until the keyboard closes.
*/
_reconcileKeyboardHold({ resize = true } = {}) {
if (!this._keyboardHeldRows || this._keyboardHoldEligible()) return;
this._releaseKeyboardHold();
if (resize && this.activeSessionId) this.sendResize(this.activeSessionId)?.catch?.(() => {});
},
/**
* End a keyboard row hold and put the screen back in place (no offset, no
* overlay bound). Shared by KeyboardHandler (keyboard hide, shape change)
* and _reconcileKeyboardHold.
* @returns {boolean} whether a hold was released
*/
_releaseKeyboardHold() {
if (!this._keyboardHeldRows) return false;
this._keyboardHeldRows = 0;
this._applyKeyboardClip();
return true;
},
/**
* Scroll the (held, taller than its box) terminal screen so the live content
* sits above the keyboard, and bound the local-echo overlay to the rows that
* are visible. Runs on every render and cursor move while the hold is active;
* resets once when it ends.
*/
_applyKeyboardClip() {
const container = this.terminal?.element?.parentElement;
if (!container) return;
const rows = this._keyboardHeldRowsActive() ? this.terminal.rows : 0;
const cellHeight = rows ? this.terminal._core?._renderService?.dimensions?.css?.cell?.height : 0;
const viewHeight = container.clientHeight;
let clip = 0;
if (cellHeight && viewHeight) {
const buffer = this.terminal.buffer.active;
const following = buffer.viewportY >= buffer.baseY;
let lastContentRow = -1;
if (following) {
for (let row = rows - 1; row >= 0; row--) {
if (buffer.getLine(buffer.baseY + row)?.translateToString(true).trim()) {
lastContentRow = row;
break;
}
}
}
clip = window.CodemanTerminalGeometry.keyboardClipPx({
rows,
cellHeight,
viewHeight,
cursorRow: buffer.cursorY,
lastContentRow,
following,
viewportY: buffer.viewportY,
baseY: buffer.baseY,
});
}
if (clip !== (this._keyboardClipPx || 0)) {
this._keyboardClipPx = clip;
if (clip) container.style.setProperty('--kb-clip', `${clip}px`);
else container.style.removeProperty('--kb-clip');
}
// Bounded whenever rows are held, even at offset 0 (a prompt near the top
// still has held rows below it, under the keyboard). setRowLimit dedupes.
this._localEchoOverlay?.setRowLimit?.(
cellHeight && viewHeight
? window.CodemanTerminalGeometry.keyboardVisibleRowLimit(clip, viewHeight, cellHeight)
: null
);
},
/**
* Fit xterm to its container and return the geometry that was APPLIED.
*
@@ -6589,7 +6704,10 @@ Object.assign(CodemanApp.prototype, {
syncTerminalGeometry() {
if (!this.fitAddon || !this.terminal) return null;
try {
this.fitAddon.fit();
// fit() would shrink xterm to the keyboard-shortened box before the held
// rows put it back: skip it while they are held (_resizeTerminalTo below
// still applies the container's columns).
if (!this._keyboardHeldRowsActive?.()) this.fitAddon.fit();
} catch {
/* a disposed or unattached terminal cannot be fitted; fall through to the read */
}
@@ -6614,8 +6732,9 @@ Object.assign(CodemanApp.prototype, {
* part of the scrollback in between. The mobile retry asks every 30 seconds,
* so that happened on a timer for as long as the refusal lasted. So the
* columns stay at the PTY's (the #464 invariant: the browser never draws at
* a width the PTY does not have), the rows follow the container (they are
* never adopted, see reconcilePtyGeometry), and the container's columns go
* a width the PTY does not have), the rows follow the container or the
* keyboard hold (they are never adopted, see reconcilePtyGeometry and
* getTerminalDimensions), and the container's columns go
* out as the request. An accepted request is adopted by the report.
*
* @returns {{cols: number, rows: number}|null} the geometry to request
+10
View File
@@ -1914,6 +1914,16 @@ export class WebServer extends EventEmitter {
'</head>',
() => `<script>window.__codemanTranscriptPageKeys=${JSON.stringify(pageKeyClis)};</script>\n</head>`
);
// Which run modes keep their PTY rows under a phone keyboard (inline renderers,
// the `keyboardHold` capability; `_keyboardHoldEligible` in terminal-ui.js). Same
// shape and reasoning as the page-key list above.
const keyboardHoldClis = enabledClis()
.filter((entry) => entry.capabilities.keyboardHold === 'inline')
.map((entry) => entry.id);
html = html.replace(
'</head>',
() => `<script>window.__codemanKeyboardHoldModes=${JSON.stringify(keyboardHoldClis)};</script>\n</head>`
);
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) {