Skip to content

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

Merged
Ark0N merged 1 commit into
Ark0N:masterfrom
JDProfresh:feat/keyboard-hold-rows
Oct 27, 2026
Merged

Ark0N merged 1 commit into
Ark0N:masterfrom
JDProfresh:feat/keyboard-hold-rows

Conversation

@JDProfresh

Copy link
Copy Markdown
Contributor

Every phone keyboard open and close resized the PTY, and Claude Code's inline renderer answers each height change by reprinting only the transcript tail that fits, so the rows above it are lost from tmux for good. This holds the rows while the keyboard is up, as agreed in #672.

Closes #672

Before and after

Claude Code 2.1.306, tmux 3.2a, a fresh session selected from the phone, one prompt printing KBTEST-001 to KBTEST-100, five keyboard cycles:

PTY per keyboard cycle SIGWINCH per cycle KBTEST lines left in tmux
master 62x38 to 62x15 and back 2 75 of 100
this PR 62x38 throughout 0 100 of 100

Running on my own install since 2026-10-21, used from iOS Safari on my iPhone (iOS 26.6.1).

What it does, per your answers on #672

  1. Default ON. Per-device setting "Keep Size Under Keyboard" (App Settings, beside Local Echo). It sits in displayKeys and stays out of the strict settings schema, which a test pins. Off restores today's behaviour.

  2. Capability keyboardHold: 'inline' on CliCapabilities (src/config/cli-registry/types.ts). The JSDoc gives the measurement above and says fullscreen Claude is unmeasured. Only the claude entry sets it. src/web/server.ts publishes it as window.__codemanKeyboardHoldModes, built from enabledClis() the same way as __codemanTranscriptPageKeys. The frontend reads only that list (_keyboardHoldEligible in terminal-ui.js), and test/frontend-cli-no-id-branching.test.ts stays green. Schema tests accept 'inline' or absent and reject true, 'fullscreen' and ''. They also pin that the stock list is exactly ['claude'].

  3. The documented exception is a paragraph right after the Text gets muffled sometimes, in both TUI default and fullscreen #464 one in docs/architecture-invariants.md (PTY and browser terminal geometry). It says why this is not the COLUMNS ONLY regression: there, the rows came from another device and nothing kept the composer in view; here, this device's own rows are held on purpose and --kb-clip keeps the last content row at the bottom of the box. The Text gets muffled sometimes, in both TUI default and fullscreen #464 paragraph now points to it, and the root CLAUDE.md summary names it. The composer test is test/keyboard-held-rows.test.ts ("the composer row stays visible with the rows held"). It runs a real @xterm/headless terminal at 38 rows with Claude's inline frame (100 transcript lines, the rule, the ❯ composer, the footer) through the shipped getTerminalDimensions and _applyKeyboardClip. It asserts:

    • the rows stay at 38 while the box proposes 15;
    • the composer and footer rows are wholly inside the visible window at the exact clip;
    • the overlay row limit is set;
    • near the top, there is no clip and the limit is 15;
    • the release removes both.

    A real-browser companion in test/mobile/keyboard.test.ts measures the composer and footer rects against #terminalContainer with the actual transform after a keyboard show.

  4. setRowLimit() stays in the package and feeds totalRows. _layoutRows() in zerolag-input-addon.ts is the one place that computes the totalRows handed to renderOverlay: the terminal's rows, narrowed by the limit, and never widened past them. Both the render key and RenderParams.totalRows read it. Six package tests are in test/zerolag-input-addon.test.ts ("setRowLimit").

  5. Fullscreen veto kept. While cliMouseTracking is on, the session resizes as today. The capability JSDoc, the invariants paragraph and the wiki all say this mode is unmeasured.

The hold is released when the keyboard closes, on a shape change (rotation, fold; this also arms the viewport settle, so the terminal refits to the new display instead of keeping the held rows unclipped until the keyboard closes), on a tab switch, on a session:updated that makes the session stop qualifying (mouse tracking on), on a reconnect, and when the setting is turned off. Remote and Docker panes never hold.

Also in the PR: zh-CN strings for the label and description (pinned through the real translator over index.html), a row in Settings-Reference.md, a bullet in Mobile-Guide.md, and an entry in src/web/CLAUDE.md pointing to the invariants paragraph. Two JSDoc comments the change made untrue are corrected (reconcilePtyGeometry, _geometryForResizeRequest).

Tests

  • I removed every guard, veto and release site one at a time, plus the CSS clip rule, and each made its test fail, then restored it. The session:updated release is tested through the shipped _onSessionUpdated. The tab-switch, reconnect and settings-save call sites are covered through _reconcileKeyboardHold's own tests, not through their large enclosing functions. A browser test that drives a tab switch with the keyboard up is easy to add if you want one.
  • npm test: 12,603 passed. Two failures also fail on unmodified master here, and neither file is touched:
  • Package tests (packages/xterm-zerolag-input): 267 passed.
  • typecheck, lint, format:check, check:frontend-syntax, check:browser-excludes and check:public-assets all pass.
  • npm run test:mobile -- test/mobile/keyboard.test.ts: 5 failed / 48 passed on the branch, against 5 failed / 47 passed on master. The five failures and their messages are identical: toolbar transform, CJK local echo, and the three tap-intent cases. The extra test is the new composer test, and it passes.

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 (Ark0N#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.
@Ark0N

Ark0N commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Thanks @JDProfresh, this is careful work. It keeps a local Claude session's PTY rows while the phone keyboard is up and translates the screen with --kb-clip so the composer stays above the keyboard, which ends the transcript loss you measured in #672.

Everything from my answers on #672 is here: default ON as a per-device setting, the keyboardHold: 'inline' capability published as window.__codemanKeyboardHoldModes, the documented exception next to the #464 paragraph, setRowLimit() feeding the existing totalRows bound, and the fullscreen veto. On my side the gate is green (633 files, 12,605 tests), the package tests pass (267), and npm run test:mobile -- test/mobile/keyboard.test.ts shows the same five failures on this branch and on the merge base, with your composer test passing.

Three small things, none of which needs a round trip:

  1. src/web/public/terminal-ui.js:6641: _applyKeyboardClip() reads container.clientHeight on every render and cursor move on every touch device, also when nothing is held and with the setting off. It runs right after _syncMobileHelperTextareaToCursor() writes two CSS custom properties, so with the DOM renderer that is an extra forced layout per frame for a value only the held path uses. Reading it only while rows are held (const viewHeight = rows ? container.clientHeight : 0;) keeps the release path intact, because the cellHeight && viewHeight guard already sends rows === 0 to the reset branch.
  2. src/web/public/constants.js:3392: the keyboardClipPx JSDoc has a stray ponytail: marker; it should read as a plain note.
  3. src/web/CLAUDE.md:55: the "Per-device vs synced settings" paragraph lists the per-device keys SettingsUpdateSchema does not declare, and keyboardHoldsRows belongs in that list now.

I will apply these three on top when I merge, so there is nothing more for you to push. Thank you for asking first on #672 and for the measurements behind it; they made this an easy review.

@Ark0N
Ark0N merged commit 3e33435 into Ark0N:master Oct 27, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 27, 2026
- terminal-ui.js _applyKeyboardClip(): read container.clientHeight only while rows are held, so the release path and the setting-off path skip the forced layout on every render and cursor move
- constants.js keyboardClipPx JSDoc: drop the stray "ponytail:" marker, the note reads as plain text
- src/web/CLAUDE.md "Per-device vs synced settings": list keyboardHoldsRows among the per-device keys SettingsUpdateSchema does not declare

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 27, 2026
@Ark0N

Ark0N commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Merged in 1.46.0 (https://github.com/Ark0N/Codeman/releases/tag/codeman%401.46.0), on by default as agreed in #672. Thank you for the measurements; 100 of 100 lines kept against 75 made the case. I applied the three items at merge: the terminal height is read only while rows are held, the stray marker in the keyboardClipPx JSDoc is gone, and keyboardHoldsRows is in the per-device key list in src/web/CLAUDE.md. The release review also checked it against #713's new overlay renderer, which caches lines: a keyboard open or close re-renders through the row bound, and 65 randomized sequences matched a fresh render at every step.

@JDProfresh
JDProfresh deleted the feat/keyboard-hold-rows branch October 28, 2026 04:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Opening the phone keyboard resizes the PTY, and Claude Code drops transcript lines for good

2 participants