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

+3 -1
View File
@@ -52,7 +52,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](../../docs/architecture-invariants.md#command-palette-and-shortcut-registry)
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `keyboardHoldsRows`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
@@ -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);
@@ -5190,6 +5192,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
@@ -9727,6 +9731,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
@@ -3861,7 +3861,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
@@ -3873,6 +3875,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.
*
* 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
@@ -4010,6 +4068,8 @@ if (typeof window !== 'undefined') {
window.CodemanTerminalGeometry = {
clampTerminalDimensions,
reconcilePtyGeometry,
keyboardClipPx,
keyboardVisibleRowLimit,
TERMINAL_MIN_COLS,
TERMINAL_MIN_ROWS,
};
+3
View File
@@ -676,6 +676,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
@@ -2000,6 +2000,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
@@ -625,6 +625,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.
@@ -3033,6 +3035,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(),
@@ -3125,6 +3128,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);
@@ -3277,6 +3282,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,
@@ -4976,7 +4984,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
@@ -23250,3 +23250,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
@@ -1061,6 +1061,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
@@ -6641,12 +6644,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 = rows ? container.clientHeight : 0;
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.
*
@@ -6674,7 +6789,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 */
}
@@ -6699,8 +6817,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
@@ -1976,6 +1976,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) {