diff --git a/CLAUDE.md b/CLAUDE.md index bd2594acd..9a2f531f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -262,7 +262,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package) -**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry) +**PTY and browser terminal geometry** (#464): a browser terminal whose width differs from the PTY's garbles Claude's redraws, so `syncTerminalGeometry()` (terminal-ui.js) is the ONE function that may resize the main terminal (never a bare `fitAddon.fit()`), a font change is a geometry change, and the fit is withheld wherever the SIGWINCH is. ⚠️ Resize is answered with `Session.ptyGeometry`, and a client adopts its COLUMNS only (never rows); `ptyGeometry` is null without a live pane. ⚠️ The one sanctioned screen taller than its box is the phone keyboard row hold (#672, `keyboardHoldsRows`, default ON, inline renderers only via `capabilities.keyboardHold`): the PTY keeps its rows while the keyboard is up (each height change makes Claude's inline renderer drop transcript lines from tmux) and `--kb-clip` keeps the last content row at the bottom of the visible box. → [architecture-invariants#pty-and-browser-terminal-geometry](docs/architecture-invariants.md#pty-and-browser-terminal-geometry) **Service worker precache** (`sw.js` + `scripts/build.mjs`): `BUILD_ID` and `HASHED_ASSETS` are build-generated and the build THROWS unless each declaration appears exactly once; `caches.match` must pass `ignoreSearch: true` because `cacheBustAssets` appends `?v=` to hashed names. → [architecture-invariants#service-worker-precache-and-cache-key](docs/architecture-invariants.md#service-worker-precache-and-cache-key) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7a743bb62..766506f4e 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1151,7 +1151,9 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### PTY and browser terminal geometry -**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY.** Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to 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, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reached scrollLeft 141 without that preventDefault and 0 with it). Removing the class returns `scrollLeft` to 0, so a resolved mismatch cannot leave the pane parked off-screen. +**The PTY and the browser terminal must never disagree about size** (issue #464, `syncTerminalGeometry()` in terminal-ui.js): Claude Code's TUI wraps its frame at the width the PTY reported and erases the previous frame by walking the cursor up the number of rows it BELIEVES that frame occupied. A browser terminal of a different width makes each logical line take more physical rows than Ink counted, so `eraseLines(n)` clears too few and the new frame paints over rows nothing erased — the doubled lines and half-overwritten prose in #464, **measured** against this repo's xterm (a 120-column PTY against a 62-column terminal renders every wrapped line twice; `test/terminal-pty-geometry.test.ts` pins it, and pins the clean render at matching widths so the assertion cannot pass against code that fixes nothing). ⚠️ **`fitAddon.fit()` is NOT the way to resize this terminal.** It resizes xterm to `proposeDimensions()` RAW while every server-facing path reports those floored at 40x10, so whenever the floor bit the two diverged silently — **measured in Chrome at 430px**: font size 44 proposed 13 columns, the server was told 40, and xterm stayed at 13. `syncTerminalGeometry()` fits, floors and applies as one step and is the ONE function that may change the size; `test/terminal-pty-geometry.test.ts` sweeps the seven modules that touch the main terminal (terminal-ui, mobile-handlers, app, ralph-panel, settings-ui, tab-rail-resize, notification-manager) for a bare fit; the split pane and teammate terminals own their own sizing and are out of scope. ⚠️ **Withhold the fit wherever you withhold the SIGWINCH.** `throttledResize` (virtual keyboard up) and `sendResize` (session detached into its own window) used to reflow locally and skip only the server write, which is the one combination that cannot be right. ⚠️ **A font change is a geometry change**: `setFontSize`/`setFontFamily`/`setFontWeight` move the cell size and told the server nothing, so raising the font on a phone left the CLI wrapping at the old column count (`_refitAfterCellSizeChange`). ⚠️ **Resize is no longer write-only.** `Session.resize` DECLINES a small-viewport request while a desktop connection holds an active sizing claim and says nothing, so both transports now answer with `Session.ptyGeometry` (`{"t":"zc"}` on the socket, the body of the resize POST) and `_onPtyGeometryReport` adopts it — a terminal that keeps a WIDTH the PTY refused renders GARBLED, not merely wrong-sized. ⚠️ While that refusal stands (`_paneWidthRefused`), a resize ASKS for the container's width without applying it (`_geometryForResizeRequest`: rows follow the container, columns stay at the PTY's): fitting first re-wrapped the whole buffer to the container and back on every 30s mobile retry, and ran the scrollback clear for a resize that brings no redraw. `selectSession` clears the flag, since it belongs to the previous pane. ⚠️ **`ptyGeometry` is null without a live pane**, never the field values: they are seeded at spawn (`_notePtySpawnGeometry`, so a reattached pane reports its tmux window's real size) and moved by `resize()`, but a dead-pane session still holds the constructor defaults of 120x40 or a gone pane's size — reporting those made a client adopt a size no process was ever told and claim another device owned the pane when none existed. ⚠️ **COLUMNS ONLY** (one documented exception, the keyboard row hold in the next paragraph). Adopting the PTY's ROWS was a regression: a phone taking a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container, xterm's own viewport then had nothing to scroll, and the CLI's input line sat below the container with no gesture able to 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, and keeping the local count keeps the composer at the bottom of a viewport that scrolls. ⚠️ **`.term-overflows-x` keys on what does not FIT, not on a PTY mismatch**, because the 40-column floor widens the terminal past a narrow container with the PTY agreeing throughout (measured at 360px: font 18 paints 433px, font 24 paints 578px — 38% unreachable, and `increaseFontSize` reaches 24 in two taps). `_syncTerminalOverflowAffordance()` MEASURES `.xterm-screen` against the container on the next frame rather than deriving it from cell arithmetic. ⚠️ That rule sets **both** overflow axes — mobile.css loads later with `.terminal-container { overflow: visible }`, and a bare `overflow-x` would leave overflow-y computing to `auto`, handing the browser a vertical scroll container the terminal's touch handler does not know about — and sets **no `touch-action`**: the terminal's own touchmove handler pans it (`canPanHorizontally`), because `touchstart` preventDefault()s every 'content' tap and that cancels a native `pan-x` before it starts (measured: a 140px swipe reached scrollLeft 141 without that preventDefault and 0 with it). Removing the class returns `scrollLeft` to 0, so a resolved mismatch cannot leave the pane parked off-screen. + +**The one exception to COLUMNS ONLY: rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): every PTY height change makes Claude Code's inline renderer clear the screen and reprint only the transcript tail that fits the new height, so the rows above that tail are gone from tmux and older ones stay in history as stale copies, and the phone keyboard opening and closing was such a change each time. **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 cycles (PTY 62x38 to 62x15 and back, one SIGWINCH per transition) left 75 of the 100 in `tmux capture-pane` history; with the rows held the PTY stayed 62x38, no SIGWINCH reached the pane and all 100 remained. So `KeyboardHandler` captures `terminal.rows` on the show transition (`app._keyboardHeldRows`) and `getTerminalDimensions()` never reports fewer rows while the keyboard is visible; columns still follow the container, so the #464 width rule is untouched. ⚠️ **Why this is not the regression the COLUMNS ONLY note describes.** There the extra rows came from ANOTHER device's sizing claim, adopted from `ptyGeometry`, and nothing kept the composer in view: an `.xterm-screen` taller than its container with the input line below it and no gesture able to reach it. Here the rows are this device's own keyboard-free rows, held on purpose only while the keyboard is up, and the screen is translated up by `--kb-clip` (`_applyKeyboardClip()`, pure math in `keyboardClipPx()` in constants.js) so the last content row sits at the bottom of the visible box: following live output, the cursor counts only within `KEYBOARD_CLIP_CURSOR_REACH` (2) rows below the last content (a TUI that parks its cursor far below what it drew must not scroll its content away); reading history, the offset follows the scroll position so the top of the scrollback stays reachable. The local-echo overlay is bounded to the visible rows through `setRowLimit()`, which narrows the overlay renderer's existing `totalRows` bound rather than adding a second one, so a long draft grows upward from the last row the user can see. `reconcilePtyGeometry` still adopts columns only: nothing here takes rows from the PTY's report. `test/keyboard-held-rows.test.ts` pins the composer row inside the visible box with the rows held, against a real headless xterm and the shipped `_applyKeyboardClip()`. ⚠️ Holding goes through `getTerminalDimensions()`, never one caller, so the fit, the PTY resize and the capture-geometry comparisons agree; `syncTerminalGeometry()` skips `fitAddon.fit()` while held, so xterm never visits the short size. ⚠️ A shape change (rotation, fold) releases the hold AND arms the viewport settle (`_scheduleViewportSettle()`), which refits and tells the PTY: with the keyboard still up `throttledResize` stands aside and a rotation alone arms nothing, so without it xterm kept the held rows with no clip and the composer sat under the keyboard until it closed. A rotation with no hold arms nothing, as before. ⚠️ ONLY inline renderers qualify (`_keyboardHoldEligible()`): the registry's `capabilities.keyboardHold: 'inline'` (Claude alone; read through `window.__codemanKeyboardHoldModes`, never an id literal), never while `cliMouseTracking` reports Claude's fullscreen mode (it lays out the full height and has not been measured losing lines, so it resizes as before), never remote or docker (their local pane hosts a nested client). A full-height layout (codex: banner at the top, composer pinned to the bottom) cannot fit both ends above the keyboard, which is what the first, unscoped version got wrong. ⚠️ The hold lives on the shared terminal, so `_reconcileKeyboardHold()` releases it whenever the active session stops qualifying (tab switch, a `session:updated`, a reconnect, the setting turned off), and keyboard close releases it too; it never re-holds with the keyboard already up, so a session that becomes eligible mid-keyboard keeps the short rows until the keyboard closes. ### Terminal resilience: replay clears, renderer liveness, fetch deadlines diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index a61714601..c090c6bf8 100644 --- a/docs/wiki/Mobile-Guide.md +++ b/docs/wiki/Mobile-Guide.md @@ -153,6 +153,12 @@ already writing in the editor. - The terminal and toolbar shift up when the keyboard opens, tracked through the browser's visual viewport rather than guessed. +- **Opening the keyboard does not resize a Claude session.** Each resize made Claude reprint + its screen at the new height and drop transcript lines from the session's history, so for a + local Claude session the terminal keeps its size while the keyboard is up and the view + scrolls instead, with the prompt and the line under it just above the keyboard. Codex, + Claude's fullscreen mode, remote and Docker sessions still resize. **Keep Size Under + Keyboard** in App Settings turns it off on this device. - **Two ways to dismiss the keyboard**: tap outside the terminal on inert space, or tap twice on inert terminal content. Tapping a control never dismisses it, and tapping the prompt row keeps focus so you can place the caret. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 19d1676be..357a4a9e7 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -21,7 +21,7 @@ desktop. | Category | Examples | | ----------------------- | ------------------------------------------------------------------------------- | -| **Per-device, local** | Skin, WebGL renderer, local echo, CJK input, extended keyboard bar, File Viewer and Cron header buttons. Never sent to the server at all. | +| **Per-device, local** | Skin, WebGL renderer, local echo, keep size under keyboard, CJK input, extended keyboard bar, File Viewer and Cron header buttons. Never sent to the server at all. | | **Per-device policy** | Most `show*` toggles, plan usage chip, language. Stored server-side, but a device only takes the server value when it has no local one of its own. | | **Synced** | Models, effort, CLI options, notification preferences, voice settings, display name, the agent skill and approvals toggles. | @@ -46,6 +46,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See | Setting | Default | Notes | | ----------------------------- | -------------------- | --------------------------------------------------------------------- | | Local Echo | On for touch devices | Paints keystrokes locally and flushes on Enter. See [Input And Voice](Input-And-Voice). | +| Keep Size Under Keyboard | On | For local Claude sessions, the on-screen keyboard no longer resizes the session: the terminal keeps its rows and the view scrolls so the prompt sits just above the keyboard. Resizing made Claude reprint its screen and drop transcript lines from history. Codex, Claude's fullscreen mode, remote and Docker sessions resize as before. See [Mobile Guide](Mobile-Guide). | | CJK Input | Off | IME composition through a dedicated text field. | | Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. | | Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. | diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md index 1129a8a85..526105944 100644 --- a/packages/xterm-zerolag-input/README.md +++ b/packages/xterm-zerolag-input/README.md @@ -205,6 +205,7 @@ Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not** | `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character and drop any IME composition. See [backspace handling](#backspace-handling). | | `clear()` | `void` | Clear all state, the composition included, and hide the overlay. Call on Enter, Ctrl+C, Escape. | | `setComposition(text)` | `void` | Show text an IME is still composing as an underlined tail after the typed text. Pass `''` to remove it. See [IME composition](#ime-composition). | +| `setRowLimit(limit)` | `void` | Narrow the row bound the overlay keeps itself on screen with (normally `terminal.rows`) to the first `limit` screen rows, so it grows upward from the last of them; `null` restores all rows, and a limit above `terminal.rows` changes nothing. For hosts that keep the terminal taller than the box showing it, e.g. while an on-screen keyboard covers the bottom. | ### Backspace handling diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts index 3993de8bf..e1320a989 100644 --- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts +++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts @@ -69,6 +69,9 @@ export class ZerolagInputAddon implements XtermAddon { private _bufferDetectDone = false; // IME text still being composed: drawn after the pending text, never sent. private _composition = ''; + // Rows from the top of the screen that are actually visible (see setRowLimit); + // null means all of them. + private _rowLimit: number | null = null; // Render cache private _lastRenderKey = ''; @@ -329,6 +332,35 @@ export class ZerolagInputAddon implements XtermAddon { } } + /** + * Bound the layout to the first `limit` rows of the screen, or to all of them + * with `null`. It narrows the bound the overlay already keeps itself on + * screen with (`totalRows`, see _layoutRows), never adds a second one. + * + * For a host that keeps the terminal taller than the box showing it and + * scrolls the screen element instead (e.g. holding the PTY's rows while an + * on-screen keyboard covers the bottom): the composer must grow upward from + * the last row the user can SEE, not from the terminal's last row, which is + * under the keyboard. + */ + setRowLimit(limit: number | null): void { + const next = limit !== null && Number.isFinite(limit) && limit > 0 ? Math.trunc(limit) : null; + if (next === this._rowLimit) return; + this._rowLimit = next; + this.rerender(); + } + + /** + * The `totalRows` bound handed to renderOverlay: the one place that decides + * where the block stops growing downward. The terminal's rows, narrowed by + * setRowLimit when the host shows fewer; a limit above the terminal's rows + * changes nothing. + */ + private _layoutRows(): number { + const rows = this._terminal!.rows; + return this._rowLimit !== null ? Math.min(this._rowLimit, rows) : rows; + } + /** * Re-read font properties from the terminal and re-render. * Call after font size changes, theme changes, etc. @@ -621,9 +653,11 @@ export class ZerolagInputAddon implements XtermAddon { // Skip redundant re-renders — include text content to detect // same-length changes (e.g., setFlushed with different text) // `rows` is part of the key: the layout is clamped to the visible rows - // (see renderOverlay), so a keyboard opening — which changes rows without - // changing the text — must not be skipped as a redundant render. - const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`; + // (see renderOverlay), so a keyboard opening, which changes rows or the + // row limit without changing the text, must not be skipped as a + // redundant render. + const totalRows = this._layoutRows(); + const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${totalRows}:${this._flushedOffset}`; if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return; this._lastRenderKey = renderKey; @@ -671,7 +705,7 @@ export class ZerolagInputAddon implements XtermAddon { charTop, charHeight, promptRow: activePrompt.row, - totalRows: this._terminal.rows, + totalRows, font: this._font, showCursor: this._options.showCursor, cursorColor, diff --git a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts index e46538687..4718cb253 100644 --- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts +++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts @@ -756,3 +756,78 @@ describe('viewport scrolled away from the bottom', () => { expect(overlay.style.display).toBe('none'); }); }); + +describe('setRowLimit', () => { + function rowLimitSetup(promptRow = 10) { + const lines = Array.from({ length: 24 }, (_, i) => (i === promptRow ? '$ ' : '')); + const mock = createMockTerminal({ buffer: { lines }, cols: 20, rows: 24, cellHeight: 17 }); + const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } }); + mock.terminal.loadAddon(addon); + cleanups.push(() => { + addon.dispose(); + mock.cleanup(); + }); + const overlay = () => mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement; + return { addon, overlay }; + } + + it('grows the composer upward from the last VISIBLE row, not the terminal last row', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); // 3 lines at 20 cols from the prompt on row 10 + expect(overlay().style.top).toBe(`${10 * 17}px`); + addon.setRowLimit(12); + expect(overlay().style.top).toBe(`${9 * 17}px`); + }); + + it('null restores the full terminal height, and junk reads as null', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); + addon.setRowLimit(12); + addon.setRowLimit(null); + expect(overlay().style.top).toBe(`${10 * 17}px`); + addon.setRowLimit(12); + addon.setRowLimit(Number.NaN); + expect(overlay().style.top).toBe(`${10 * 17}px`); + }); + + // setRowLimit narrows the SAME bound renderOverlay already keeps the block on + // screen with (RenderParams.totalRows, filled from terminal.rows), so there is + // one place that decides where the block stops growing downward. + it('narrows the terminal-rows bound and never widens it', () => { + const { addon, overlay } = rowLimitSetup(22); + addon.appendText('x'.repeat(50)); // 3 lines from row 22 of 24: the terminal bound lifts it to row 21 + expect(overlay().style.top).toBe(`${21 * 17}px`); + addon.setRowLimit(24); + expect(overlay().style.top).toBe(`${21 * 17}px`); + addon.setRowLimit(100); + expect(overlay().style.top).toBe(`${21 * 17}px`); + addon.setRowLimit(23); + expect(overlay().style.top).toBe(`${20 * 17}px`); + }); + + it('leaves a block that fits above the limit where it is', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); + addon.setRowLimit(13); // rows 10..12 are exactly the three lines + expect(overlay().style.top).toBe(`${10 * 17}px`); + }); + + it('keeps the TAIL of a block taller than the limit', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); // lines of 18, 20 and 12 characters + addon.setRowLimit(2); + expect(overlay().style.top).toBe('0px'); + const xs = [...overlay().querySelectorAll(':scope > div')].map( + (line) => (line.textContent ?? '').replace(/[^x]/g, '').length + ); + expect(xs).toEqual([20, 12]); + }); + + it('holds across later keystrokes, not only the re-render it triggers', () => { + const { addon, overlay } = rowLimitSetup(); + addon.appendText('x'.repeat(50)); + addon.setRowLimit(12); + addon.appendText('y'); + expect(overlay().style.top).toBe(`${9 * 17}px`); + }); +}); diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index de4a5542f..3cd335288 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -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']), diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 1b0e00ea9..41425f7bc 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -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', diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 81fd48b44..ede7dd342 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -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; diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 1c884dfce..4a2bc7bbc 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -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) diff --git a/src/web/public/app.js b/src/web/public/app.js index db6551312..c26ded85a 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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 diff --git a/src/web/public/constants.js b/src/web/public/constants.js index eb16edcde..ca9939001 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -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, }; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 0f17c78d4..045273228 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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)': '手势控制(测试版)', diff --git a/src/web/public/index.html b/src/web/public/index.html index b52a2956b..5838005d8 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1944,6 +1944,13 @@

Terminal & Input

+
+
+ 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. +
+ +
CJK Input diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index f63b848c8..e172683e7 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -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) { diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index db745f450..4b71bd24a 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -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', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 49af0d3e8..0b5b9f477 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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))); +} diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index dd0066521..cdc86730a 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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 diff --git a/src/web/server.ts b/src/web/server.ts index 2b3e582c4..8bcb0b6f2 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1914,6 +1914,16 @@ export class WebServer extends EventEmitter { '', () => `\n` ); + // 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( + '', + () => `\n` + ); if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') { html = html.replace('', () => `\n`); if (settings.gestureControlEnabled === true) { diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 9fa1c6ee5..6e62cfd16 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -485,6 +485,30 @@ describe('capability shapes', () => { }, 'transcriptPageKeys is a boolean'); }); + it("takes keyboardHold as an optional 'inline', absent meaning the keyboard resizes", () => { + const inline = baseEntry(); + (inline.capabilities as Record).keyboardHold = 'inline'; + expect(CliEntrySchema.safeParse(inline).success).toBe(true); + const absent = baseEntry(); + delete (absent.capabilities as Record).keyboardHold; + expect(CliEntrySchema.safeParse(absent).success).toBe(true); + for (const value of [true, 'fullscreen', '']) { + expectRejected( + (e) => { + (e.capabilities as Record).keyboardHold = value; + }, + `keyboardHold=${JSON.stringify(value)} names no renderer kind` + ); + } + }); + + it('declares keyboardHold for exactly the inline renderer measured losing lines on a height change', () => { + // Claude Code 2.1.306's inline renderer was measured dropping transcript lines from + // tmux on keyboard-driven height changes; every other stock entry resizes. + const holding = STOCK_CLIS.filter((e) => e.capabilities.keyboardHold === 'inline').map((e) => e.id as string); + expect(holding).toEqual(['claude']); + }); + it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => { // Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot // forward without a version gate. Unmeasured entries stay 'never'. diff --git a/test/keyboard-held-rows.test.ts b/test/keyboard-held-rows.test.ts new file mode 100644 index 000000000..a01f14858 --- /dev/null +++ b/test/keyboard-held-rows.test.ts @@ -0,0 +1,619 @@ +// Port: none (pure logic and a real headless xterm in vm contexts: no browser, +// no server). +// +// Rows held under the on-screen keyboard (`keyboardHoldsRows`, per-device, +// default ON). Every PTY height change makes Claude Code clear the screen and +// reprint only the transcript tail that fits, and the keyboard opening and +// closing used to be such a change each time (measured on the dev tier: tmux +// went 62x38 -> 62x15 -> 62x38 with a SIGWINCH per transition). Now the rows +// captured at the show transition are held, columns still follow the box, and +// the over-tall screen is scrolled up instead (keyboardClipPx). +// +// Outside test/mobile/ on purpose: that suite is not in the CI gate. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; +import xtermHeadless from '@xterm/headless'; + +type HeadlessBuffer = { + viewportY: number; + baseY: number; + cursorY: number; + length: number; + getLine(y: number): { translateToString(trim?: boolean): string } | undefined; +}; +const { Terminal: HeadlessTerminal } = xtermHeadless as unknown as { + Terminal: new (opts: Record) => { + 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,"); + }); +}); diff --git a/test/keyboard-hold-i18n.test.ts b/test/keyboard-hold-i18n.test.ts new file mode 100644 index 000000000..f41ab057a --- /dev/null +++ b/test/keyboard-hold-i18n.test.ts @@ -0,0 +1,67 @@ +/** + * @fileoverview The "Keep Size Under Keyboard" App Settings row (#672) in Chinese (zh-CN): + * its label and description run through the real translator over the real index.html in + * JSDOM, read in Chinese with no English left beyond `Claude` and `Codex`, and each key is + * in the dictionary once (a repeated key in the object literal silently replaces the first). + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); + +type Api = { configure(o: Record): void; start(): void }; + +/** What may stay Latin: the two CLI names. */ +const leftover = (text: string) => text.replace(/\b(Claude|Codex)\b/g, '').match(/[A-Za-z]+/g) ?? []; + +function rowStrings(doc: Document): { label: string; description: string } { + const row = doc.getElementById('appSettingsKeyboardHoldsRows')!.closest('.set-row')!; + return { + label: row.querySelector('.set-row-label')!.textContent!.trim(), + description: row.querySelector('.set-row-desc')!.textContent!.trim(), + }; +} + +const source = new JSDOM(INDEX); +const english = rowStrings(source.window.document); + +const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); +vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); +const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; +api.start(); +api.configure({ language: 'zh-CN' }); +const chinese = rowStrings(dom.window.document); + +afterAll(() => { + source.window.close(); + dom.window.close(); +}); + +describe('the Keep Size Under Keyboard row in zh-CN', () => { + it('finds the row (the check is not vacuous)', () => { + expect(english.label).toBe('Keep Size Under Keyboard'); + expect(english.description).toMatch(/^For local Claude sessions, opening the on-screen keyboard/); + }); + + it('translates the label and the description, with no English left', () => { + expect(chinese).toEqual({ + label: '键盘弹出时保持尺寸', + description: + '对于本地 Claude 会话,打开屏幕键盘时会保持会话尺寸并改为滚动视图,这样 Claude 不会重绘屏幕而丢失对话记录行。全屏界面(Codex、Claude 的全屏模式)仍像以前一样调整尺寸。', + }); + for (const key of ['label', 'description'] as const) expect(leftover(chinese[key])).toEqual([]); + }); + + it('adds each key to the dictionary once', () => { + for (const text of [english.label, english.description]) { + expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1); + } + }); +}); diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index a6ddb7652..1dfd4c971 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -323,6 +323,99 @@ describe('Virtual Keyboard', () => { expect(mainPadding).toBe(''); }); + it('holds the PTY rows under the keyboard and keeps the Claude composer inside the visible box', async () => { + // #672: a keyboard-driven height change makes Claude's inline renderer reprint + // only the tail that fits and lose the rest, so the rows are held instead, and + // --kb-clip has to keep the composer in view of a screen now taller than its box. + const modes = await page.evaluate(() => (window as any).__codemanKeyboardHoldModes); + expect(modes).toEqual(['claude']); + const rowsBefore = await page.evaluate(async () => { + (window as any).__resizes = []; + app.activeSessionId = 'mobile-keyboard-hold-test'; + app.sessions.set('mobile-keyboard-hold-test', { + id: 'mobile-keyboard-hold-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + // Fit the terminal to its box first, as a selected session is: these are + // the keyboard-free rows the hold must keep. + app.syncTerminalGeometry(); + app.sendResize = (id: string) => { + (window as any).__resizes.push({ id, ...app.getTerminalDimensions() }); + return Promise.resolve(true); + }; + app.terminal.reset(); + const cols = app.terminal.cols; + const rule = '\u2500'.repeat(cols); + const transcript = Array.from({ length: 100 }, (_, i) => `KBTEST-${String(i + 1).padStart(3, '0')}`); + const frame = [...transcript, rule, '\u276f ', rule, ' ? for shortcuts'].join('\r\n'); + await new Promise((resolve) => app.terminal.write(`${frame}\x1b[2A\x1b[3G`, resolve)); + return app.terminal.rows; + }); + + await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT); + await page.waitForTimeout(800); + + const held = await page.evaluate(() => { + const term = app.terminal; + const buffer = term.buffer.active; + const rowOf = (pattern: RegExp) => { + for (let row = 0; row < term.rows; row++) { + if (pattern.test(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')) return row; + } + return -1; + }; + const container = document.getElementById('terminalContainer') as HTMLElement; + const box = container.getBoundingClientRect(); + const screen = (container.querySelector('.xterm-screen') as HTMLElement).getBoundingClientRect(); + const cell = term._core._renderService.dimensions.css.cell.height; + const composer = rowOf(/^\u276f/); + const footer = rowOf(/\? for shortcuts/); + return { + rows: term.rows, + heldRows: app._keyboardHeldRows, + clip: container.style.getPropertyValue('--kb-clip'), + composer, + footer, + // Where the rows land on the page with the clip applied. + composerTop: screen.top + composer * cell, + footerBottom: screen.top + (footer + 1) * cell, + // Where they would land without it: below the box, i.e. under the keyboard. + footerBottomUnclipped: box.top + (footer + 1) * cell, + boxTop: box.top, + boxBottom: box.bottom, + viewportBottom: + (window.visualViewport?.offsetTop ?? 0) + (window.visualViewport?.height ?? window.innerHeight), + resizes: (window as any).__resizes, + }; + }); + + expect(held.rows).toBe(rowsBefore); + expect(held.heldRows).toBe(rowsBefore); + // Every resize sent while the keyboard was up carried the held rows. + for (const r of held.resizes) expect(r.rows).toBe(rowsBefore); + expect([held.composer, held.footer]).toEqual([rowsBefore - 3, rowsBefore - 1]); + // Not vacuous: unclipped, the footer would sit below the box. + expect(held.footerBottomUnclipped).toBeGreaterThan(held.boxBottom + 1); + expect(parseFloat(held.clip)).toBeGreaterThan(0); + expect(held.composerTop).toBeGreaterThanOrEqual(held.boxTop - 1); + expect(held.footerBottom).toBeLessThanOrEqual(held.boxBottom + 1); + expect(held.boxBottom).toBeLessThanOrEqual(held.viewportBottom + 1); + + await hideKeyboard(page); + await page.waitForTimeout(800); + // Released: no hold, no offset, and the rows follow the box again (the box + // after a keyboard cycle need not be the one before it, so compare with the fit). + const released = await page.evaluate(() => ({ + heldRows: app._keyboardHeldRows, + clip: (document.getElementById('terminalContainer') as HTMLElement).style.getPropertyValue('--kb-clip'), + rows: app.terminal.rows, + fits: window.CodemanTerminalGeometry.clampTerminalDimensions(app.fitAddon.proposeDimensions()).rows, + })); + expect(released).toEqual({ heldRows: 0, clip: '', rows: released.fits, fits: released.fits }); + }); + it('coalesces keyboard animation frames into one final terminal fit', async () => { const result = await page.evaluate(async () => { // `app.terminal` and `app.fitAddon` are only assigned by initTerminal(), diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index fc81713c3..7e0931d4b 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -181,6 +181,16 @@ describe('WebServer.renderIndexHtml', () => { } }); + it('injects the keyboard-hold modes from the keyboardHold capability: inline Claude, never full-height codex', async () => { + for (const solo of [undefined, 'sess-123']) { + const { server } = makeServer(); + const html = await render(server, solo); + const match = html.match(/window\.__codemanKeyboardHoldModes=(\[[^<]*\]);/); + expect(match).not.toBeNull(); + expect(JSON.parse(match![1])).toEqual(['claude']); + } + }); + it('escapes the solo id so it cannot break out of the inline '); diff --git a/test/server-index-title.test.ts b/test/server-index-title.test.ts index 726b5f094..b1cb73797 100644 --- a/test/server-index-title.test.ts +++ b/test/server-index-title.test.ts @@ -115,7 +115,9 @@ describe('WebServer index.html templating (#82)', () => { // enabled CLI declares a gutter, so it needs stripping on every machine. .replace(/<script>window\.__codemanTranscriptGutter=\{.*?\};<\/script>\n/, '') // Likewise the page-key mode list: an array, possibly empty, always injected. - .replace(/<script>window\.__codemanTranscriptPageKeys=\[.*?\];<\/script>\n/, ''); + .replace(/<script>window\.__codemanTranscriptPageKeys=\[.*?\];<\/script>\n/, '') + // And the keyboard-hold mode list, built the same way. + .replace(/<script>window\.__codemanKeyboardHoldModes=\[.*?\];<\/script>\n/, ''); const beforeTitle = rawTemplate.split('<title>Codeman')[0]; const afterTitle = rawTemplate.split('Codeman')[1]; expect(html.startsWith(beforeTitle)).toBe(true);