mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge pull request #711: fix(mobile): hold the PTY rows while the on-screen keyboard is up
This commit is contained in:
commit
98526527c8
26 files changed
+1227
-15
No files matched your search
+3
-1
@@ -52,7 +52,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte into the PTY. ⚠️ The global Escape handler (app.js) calls EVERY close method on every Escape, in the capture phase, so a close method that does more than hide (the palette and Session Manager restore focus) must return early when its overlay is not open. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)**: with no selection it must `return true` without `preventDefault()` or the interrupt is lost; keep `copyTerminalSelection` out of `SHORTCUT_ACTIONS`. The gate tests the CLEANED selection (`CodemanCopySelection.clean`: trailing padding, plus a LEADING margin only up to the width the CLI declares in `capabilities.transcriptGutter`, never one derived from the pane); the strip is not idempotent, so clean once and pass the RAW selection on, and leave Alt+drag column selections untouched. → [architecture-invariants#command-palette-and-shortcut-registry](../../docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
|
||||
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
|
||||
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `keyboardHoldsRows`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
|
||||
|
||||
**Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `<details>` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `<select>`s, which stay the source of truth. ⚠️ `.modal-tabs*` classes are retired; `admin-ui.js` needs `.set-rail-items` + `.set-doc` to survive any restructure. Guard: `test/app-settings-structure.test.ts`. → [architecture-invariants#settings-surface-app-settings-session-options-add-case](../../docs/architecture-invariants.md#settings-surface-app-settings-session-options-add-case)
|
||||
|
||||
@@ -66,6 +66,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge**: ⚠️ in `handleViewportResize()`, a visual-viewport resize that changes the WIDTH is a shape change (rotation, fold) and must never be read as the keyboard; it re-baselines instead, or `keyboardVisible` latches with no keyboard. ⚠️ `init()` must seed `lastViewportWidth`. ⚠️ With the keyboard up, a shape change baselines to `window.innerHeight`, never the shrunk visual height. ⚠️ The hinge is reserved via `--fold-inline-end`/`--fold-block-end` (0px when unfolded): each overlay fold rule must re-state its own gutter, a base gutter overridden by a later `@media` block needs its own fold restatement there on a zero base, and dialogs use physical sides (left/top segment) in every language. Guard: `test/foldable-layout.test.ts`. → [architecture-invariants#folding-devices](../../docs/architecture-invariants.md#folding-devices)
|
||||
|
||||
**Rows held under the on-screen keyboard** (#672, `keyboardHoldsRows`, per-device, default ON): while the keyboard is up, an eligible session keeps its PTY rows (`app._keyboardHeldRows`, captured by `KeyboardHandler` on the show transition and read through `getTerminalDimensions()`) and `.xterm-screen` is translated up by `--kb-clip` (`_applyKeyboardClip()`, `keyboardClipPx()`), with the local-echo overlay bounded through `setRowLimit()`. ⚠️ Eligibility is `_keyboardHoldEligible()` only (the published `window.__codemanKeyboardHoldModes`, never an id; never with `cliMouseTracking`, remote or docker). ⚠️ Every path that can change eligibility calls `_reconcileKeyboardHold()` (tab switch, `session:updated`, reconnect, settings save), keyboard hide and a shape change release it (a shape change also arms the viewport settle, the only refit while the keyboard is up), and nothing re-holds with the keyboard already up. → [architecture-invariants#pty-and-browser-terminal-geometry](../../docs/architecture-invariants.md#pty-and-browser-terminal-geometry)
|
||||
|
||||
**WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](../../docs/architecture-invariants.md#webgl-renderer-toggle)
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
|
||||
|
||||
@@ -2452,6 +2452,8 @@ class CodemanApp {
|
||||
if (session.id === this.activeSessionId && session.tokens) {
|
||||
this.updateRespawnTokens(session.tokens);
|
||||
}
|
||||
// cliMouseTracking (Claude's fullscreen mode) ends a keyboard row hold.
|
||||
if (session.id === this.activeSessionId) this._reconcileKeyboardHold?.();
|
||||
// Update parentSessionName for any subagents belonging to this session
|
||||
// (fixes stale name display after session rename)
|
||||
this.updateSubagentParentNames(session.id);
|
||||
@@ -5190,6 +5192,8 @@ class CodemanApp {
|
||||
// backoff reconnect, but if it is not on this session (dead socket, or
|
||||
// a give-up) nothing else would re-establish it from here.
|
||||
if (this._wsSessionId !== restoreId) this._connectWs(restoreId);
|
||||
// The fresh session record may no longer qualify for a keyboard row hold.
|
||||
this._reconcileKeyboardHold?.();
|
||||
void this._onSessionNeedsRefresh({ id: restoreId });
|
||||
// The split pane's second terminal reconnects on its own backoff (up to
|
||||
// 10 s between tries); the server is back now, so skip the wait. A no-op
|
||||
@@ -9727,6 +9731,9 @@ class CodemanApp {
|
||||
this._cleanupPreviousSession(sessionId);
|
||||
this.activeSessionId = sessionId;
|
||||
this._activateFileBrowserSession?.(sessionId);
|
||||
// A keyboard row hold belongs to the session it was taken for: release it here
|
||||
// if this one resizes instead (the geometry work below then fits it).
|
||||
this._reconcileKeyboardHold?.({ resize: false });
|
||||
// Repaint the partial-history banner for the tab being switched TO. The
|
||||
// replay paths refresh it when their fetch lands; without this the previous
|
||||
// session's notice stays on screen until then (#258). The switch lands at
|
||||
|
||||
@@ -3861,7 +3861,9 @@ function clampTerminalDimensions(proposed) {
|
||||
* could reach it. Output visible, typing invisible, for as long as the claim
|
||||
* stayed hot. Width is the axis the wrap arithmetic depends on; rows only
|
||||
* decide how much is on screen at once, and keeping the local row count keeps
|
||||
* the composer at the bottom of a viewport that scrolls.
|
||||
* the composer at the bottom of a viewport that scrolls. The one deliberate
|
||||
* screen taller than its box is this device's own rows held under the
|
||||
* on-screen keyboard, which keyboardClipPx keeps the composer in view for.
|
||||
*
|
||||
* @param {{cols: number, rows: number}|null} local - what xterm currently holds
|
||||
* @param {{cols: number, rows: number}|null} pty - what the server just reported
|
||||
@@ -3873,6 +3875,62 @@ function reconcilePtyGeometry(local, pty) {
|
||||
return { adopt: true, cols: pty.cols };
|
||||
}
|
||||
|
||||
/** Rows below the last content within which the cursor still counts (keyboardClipPx). */
|
||||
const KEYBOARD_CLIP_CURSOR_REACH = 2;
|
||||
|
||||
/**
|
||||
* How far to scroll the terminal SCREEN up while its rows are held under the
|
||||
* on-screen keyboard (setting `keyboardHoldsRows`).
|
||||
*
|
||||
* Every PTY height change makes Claude Code clear the screen and reprint only
|
||||
* the transcript tail that fits, so rows above that tail are lost from tmux and
|
||||
* older ones stay in history as stale copies. The keyboard opening and closing
|
||||
* was such a change each time. Instead the rows stay as they were and
|
||||
* `.xterm-screen` (taller than its box now) is translated up by this many px.
|
||||
*
|
||||
* This is the one deliberate exception to "a screen taller than its container
|
||||
* hides the input line" (see reconcilePtyGeometry): the offset below is what
|
||||
* keeps the live content in view.
|
||||
*
|
||||
* - Following live output: the last row with content sits at the bottom of the
|
||||
* box. A fresh shell prompting on row 1 shows the top; Claude's footer under
|
||||
* its composer stays visible. The cursor counts only when it is right below
|
||||
* that content (a prompt or composer line still empty): a TUI that parks its
|
||||
* cursor far below what it drew (codex's dialogs, with the cursor on the last
|
||||
* row of an otherwise blank screen) must not scroll its content out of view.
|
||||
* - Reading history: the offset follows the scroll position, so the top of the
|
||||
* scrollback is reachable and scrolling stays continuous.
|
||||
*
|
||||
* Recomputed on every render with no hysteresis; add some if a TUI's mid-redraw
|
||||
* frames ever make the view jitter.
|
||||
*
|
||||
* @param {{rows: number, cellHeight: number, viewHeight: number, cursorRow: number,
|
||||
* lastContentRow: number, following: boolean, viewportY: number, baseY: number}} input
|
||||
* lastContentRow: the last non-blank screen row, or -1 when the screen is blank
|
||||
* @returns {number} px, between 0 and the screen's overflow
|
||||
*/
|
||||
function keyboardClipPx(input) {
|
||||
const { rows, cellHeight, viewHeight, cursorRow, lastContentRow, following, viewportY, baseY } = input;
|
||||
if (!(cellHeight > 0) || !(viewHeight > 0) || !(rows > 0)) return 0;
|
||||
const max = Math.max(0, rows * cellHeight - viewHeight);
|
||||
if (max === 0) return 0;
|
||||
if (!following && baseY > 0) return Math.round(max * Math.min(1, Math.max(0, viewportY / baseY)));
|
||||
const cursorCounts = lastContentRow < 0 || cursorRow <= lastContentRow + KEYBOARD_CLIP_CURSOR_REACH;
|
||||
const contentBottomRow = cursorCounts ? Math.max(cursorRow, lastContentRow) : lastContentRow;
|
||||
const bottom = (Math.min(rows - 1, Math.max(0, contentBottomRow)) + 1) * cellHeight;
|
||||
return Math.round(Math.min(max, Math.max(0, bottom - viewHeight)));
|
||||
}
|
||||
|
||||
/**
|
||||
* How many screen rows, from the top, are fully visible at a given offset: the
|
||||
* local-echo overlay grows upward from the last of them (`setRowLimit`).
|
||||
* @returns {number}
|
||||
*/
|
||||
function keyboardVisibleRowLimit(clipPx, viewHeight, cellHeight) {
|
||||
if (!(cellHeight > 0)) return 0;
|
||||
return Math.max(1, Math.floor((clipPx + viewHeight) / cellHeight));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which session does a dashboard URL's fragment ask for? Another page that
|
||||
* holds the dashboard's window, such as a task board, points it at
|
||||
@@ -4010,6 +4068,8 @@ if (typeof window !== 'undefined') {
|
||||
window.CodemanTerminalGeometry = {
|
||||
clampTerminalDimensions,
|
||||
reconcilePtyGeometry,
|
||||
keyboardClipPx,
|
||||
keyboardVisibleRowLimit,
|
||||
TERMINAL_MIN_COLS,
|
||||
TERMINAL_MIN_ROWS,
|
||||
};
|
||||
|
||||
@@ -676,6 +676,9 @@
|
||||
'Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.':
|
||||
'终端粗体文本的字重。仅当字体提供比其粗体更重的字形时才有意义。',
|
||||
'Local Echo': '本地回显',
|
||||
'Keep Size Under Keyboard': '键盘弹出时保持尺寸',
|
||||
"For local Claude sessions, opening the on-screen keyboard keeps the session's size and scrolls the view instead, so Claude does not reprint its screen and lose transcript lines. Full-screen interfaces (Codex, Claude's fullscreen mode) are resized as before.":
|
||||
'对于本地 Claude 会话,打开屏幕键盘时会保持会话尺寸并改为滚动视图,这样 Claude 不会重绘屏幕而丢失对话记录行。全屏界面(Codex、Claude 的全屏模式)仍像以前一样调整尺寸。',
|
||||
'CJK Input': '中日韩输入',
|
||||
'Extended Keyboard Bar': '扩展键盘栏',
|
||||
'Gesture Control (beta)': '手势控制(测试版)',
|
||||
|
||||
@@ -2000,6 +2000,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLocalEcho"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="keyboard hold rows resize redraw mobile">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep Size Under Keyboard</span>
|
||||
<span class="set-row-desc">For local Claude sessions, opening the on-screen keyboard keeps the session's size and scrolls the view instead, so Claude does not reprint its screen and lose transcript lines. Full-screen interfaces (Codex, Claude's fullscreen mode) are resized as before.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsKeyboardHoldsRows"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="cjk input ime chinese japanese korean">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">CJK Input</span>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -625,6 +625,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||
this.loadTunnelStatus();
|
||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
// Default ON, so an absent key reads as enabled rather than as off.
|
||||
document.getElementById('appSettingsKeyboardHoldsRows').checked = settings.keyboardHoldsRows !== false;
|
||||
// Auto Copy (copy-on-select): per-device, default OFF everywhere. It quietly
|
||||
// overwrites the system clipboard on a gesture the user may have meant only as
|
||||
// a way to read, so it is opt-in rather than a default anyone has to discover.
|
||||
@@ -3033,6 +3035,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
Math.min(24, Math.max(0.5, parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2)),
|
||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||
keyboardHoldsRows: document.getElementById('appSettingsKeyboardHoldsRows').checked,
|
||||
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
|
||||
copyStripMargin: document.getElementById('appSettingsCopyStripMargin').checked,
|
||||
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
|
||||
@@ -3125,6 +3128,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Save to localStorage
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
this._updateLocalEchoState();
|
||||
// Turning Keep Size Under Keyboard off while it holds releases it now.
|
||||
this._reconcileKeyboardHold?.();
|
||||
this.applyTerminalFontFamily?.(settings.terminalFontFamily);
|
||||
this.applyTerminalFontWeights?.(settings);
|
||||
|
||||
@@ -3277,6 +3282,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
const _chipFlip = this.planUsageCollectionFlip(_prev, settings.showPlanUsageLimits);
|
||||
const {
|
||||
localEchoEnabled: _leo,
|
||||
// Per-device (it is about this screen's keyboard) and absent from
|
||||
// SettingsUpdateSchema (.strict()), so sending it would 400 the whole PUT.
|
||||
keyboardHoldsRows: _khr,
|
||||
cjkInputEnabled: _cjk,
|
||||
extendedKeyboardBar: _ekb,
|
||||
skin: _skin,
|
||||
@@ -4976,7 +4984,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showFontControls', 'showSystemStats', 'showGpuStats', 'showCpuMhz', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'keyboardHoldsRows', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
|
||||
@@ -23250,3 +23250,12 @@ html[data-header-stats='tiles'] .header-plan-usage:not(:has(.pu-row)) {
|
||||
border: 1px solid var(--control-border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Rows held under the on-screen keyboard (keyboardHoldsRows; keyboardClipPx in
|
||||
constants.js): the screen stays taller than its box and is scrolled up by
|
||||
--kb-clip, set on #terminalContainer by _applyKeyboardClip (terminal-ui.js).
|
||||
The helper textarea, IME preview, link layer and both echo overlays live
|
||||
inside .xterm-screen, so they move with it, and every hit test reads rects. */
|
||||
body.keyboard-visible .terminal-container .xterm-screen {
|
||||
transform: translateY(calc(-1 * var(--kb-clip, 0px)));
|
||||
}
|
||||
@@ -1061,6 +1061,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (MobileDetection.isTouchDevice()) {
|
||||
this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor());
|
||||
this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor());
|
||||
// Keep the live content above the keyboard while the rows are held.
|
||||
this.terminal.onCursorMove(() => this._applyKeyboardClip());
|
||||
this.terminal.onRender(() => this._applyKeyboardClip());
|
||||
}
|
||||
|
||||
// Renderer liveness — see _startRenderLivenessWatchdog. Registered for every
|
||||
@@ -6641,12 +6644,124 @@ Object.assign(CodemanApp.prototype, {
|
||||
// timer and the resize observer, where an exception takes the rest of the
|
||||
// callback (the padding fit, the scroll restore, the SIGWINCH) with it.
|
||||
try {
|
||||
return window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions());
|
||||
const dims = window.CodemanTerminalGeometry.clampTerminalDimensions(this.fitAddon?.proposeDimensions());
|
||||
// Rows held under the on-screen keyboard (see keyboardClipPx): the
|
||||
// container shrank, the PTY must not. Columns still follow the container.
|
||||
const held = this._keyboardHeldRowsActive?.();
|
||||
if (dims && held) dims.rows = Math.max(dims.rows, held);
|
||||
return dims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Rows held while the on-screen keyboard is up, or 0. Set by KeyboardHandler
|
||||
* on the show transition when `keyboardHoldsRows` is on (default), cleared on
|
||||
* hide and on a shape change. Every geometry read goes through
|
||||
* getTerminalDimensions(), so holding it there holds the fit, the PTY resize
|
||||
* and every capture-geometry comparison alike.
|
||||
* @returns {number}
|
||||
*/
|
||||
_keyboardHeldRowsActive() {
|
||||
if (!(this._keyboardHeldRows > 0)) return 0;
|
||||
return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible ? this._keyboardHeldRows : 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* May the keyboard hold this session's rows? Only an INLINE renderer (the
|
||||
* `keyboardHold` capability, read from `window.__codemanKeyboardHoldModes`,
|
||||
* never an id literal here): a CLI that lays out the full height (codex) cannot
|
||||
* show both its content and its composer above the keyboard and must be
|
||||
* resized, as before. Never while the CLI reports mouse tracking (Claude's
|
||||
* fullscreen mode), and never for a remote or docker session, whose pane hosts
|
||||
* a nested client. Absent data means no hold. Per-device setting
|
||||
* `keyboardHoldsRows`, default ON.
|
||||
*/
|
||||
_keyboardHoldEligible(sessionId = this.activeSessionId) {
|
||||
if (this.loadAppSettingsFromStorage?.()?.keyboardHoldsRows === false) return false;
|
||||
const session = sessionId ? this.sessions?.get(sessionId) : null;
|
||||
if (!session || session.remote || session.docker || session.cliMouseTracking) return false;
|
||||
const modes = window.__codemanKeyboardHoldModes;
|
||||
return Array.isArray(modes) && modes.includes(session.mode || 'claude');
|
||||
},
|
||||
|
||||
/**
|
||||
* Drop a hold the active session no longer qualifies for (switched to a CLI
|
||||
* that resizes, entered fullscreen, setting turned off) and, unless the caller
|
||||
* resizes itself (selectSession), fit and tell the PTY at once, which is the
|
||||
* old keyboard behavior. Never re-holds: a session that becomes eligible with
|
||||
* the keyboard already up keeps the short rows until the keyboard closes.
|
||||
*/
|
||||
_reconcileKeyboardHold({ resize = true } = {}) {
|
||||
if (!this._keyboardHeldRows || this._keyboardHoldEligible()) return;
|
||||
this._releaseKeyboardHold();
|
||||
if (resize && this.activeSessionId) this.sendResize(this.activeSessionId)?.catch?.(() => {});
|
||||
},
|
||||
|
||||
/**
|
||||
* End a keyboard row hold and put the screen back in place (no offset, no
|
||||
* overlay bound). Shared by KeyboardHandler (keyboard hide, shape change)
|
||||
* and _reconcileKeyboardHold.
|
||||
* @returns {boolean} whether a hold was released
|
||||
*/
|
||||
_releaseKeyboardHold() {
|
||||
if (!this._keyboardHeldRows) return false;
|
||||
this._keyboardHeldRows = 0;
|
||||
this._applyKeyboardClip();
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Scroll the (held, taller than its box) terminal screen so the live content
|
||||
* sits above the keyboard, and bound the local-echo overlay to the rows that
|
||||
* are visible. Runs on every render and cursor move while the hold is active;
|
||||
* resets once when it ends.
|
||||
*/
|
||||
_applyKeyboardClip() {
|
||||
const container = this.terminal?.element?.parentElement;
|
||||
if (!container) return;
|
||||
const rows = this._keyboardHeldRowsActive() ? this.terminal.rows : 0;
|
||||
const cellHeight = rows ? this.terminal._core?._renderService?.dimensions?.css?.cell?.height : 0;
|
||||
const viewHeight = rows ? container.clientHeight : 0;
|
||||
let clip = 0;
|
||||
if (cellHeight && viewHeight) {
|
||||
const buffer = this.terminal.buffer.active;
|
||||
const following = buffer.viewportY >= buffer.baseY;
|
||||
let lastContentRow = -1;
|
||||
if (following) {
|
||||
for (let row = rows - 1; row >= 0; row--) {
|
||||
if (buffer.getLine(buffer.baseY + row)?.translateToString(true).trim()) {
|
||||
lastContentRow = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
clip = window.CodemanTerminalGeometry.keyboardClipPx({
|
||||
rows,
|
||||
cellHeight,
|
||||
viewHeight,
|
||||
cursorRow: buffer.cursorY,
|
||||
lastContentRow,
|
||||
following,
|
||||
viewportY: buffer.viewportY,
|
||||
baseY: buffer.baseY,
|
||||
});
|
||||
}
|
||||
if (clip !== (this._keyboardClipPx || 0)) {
|
||||
this._keyboardClipPx = clip;
|
||||
if (clip) container.style.setProperty('--kb-clip', `${clip}px`);
|
||||
else container.style.removeProperty('--kb-clip');
|
||||
}
|
||||
// Bounded whenever rows are held, even at offset 0 (a prompt near the top
|
||||
// still has held rows below it, under the keyboard). setRowLimit dedupes.
|
||||
this._localEchoOverlay?.setRowLimit?.(
|
||||
cellHeight && viewHeight
|
||||
? window.CodemanTerminalGeometry.keyboardVisibleRowLimit(clip, viewHeight, cellHeight)
|
||||
: null
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fit xterm to its container and return the geometry that was APPLIED.
|
||||
*
|
||||
@@ -6674,7 +6789,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
syncTerminalGeometry() {
|
||||
if (!this.fitAddon || !this.terminal) return null;
|
||||
try {
|
||||
this.fitAddon.fit();
|
||||
// fit() would shrink xterm to the keyboard-shortened box before the held
|
||||
// rows put it back: skip it while they are held (_resizeTerminalTo below
|
||||
// still applies the container's columns).
|
||||
if (!this._keyboardHeldRowsActive?.()) this.fitAddon.fit();
|
||||
} catch {
|
||||
/* a disposed or unattached terminal cannot be fitted; fall through to the read */
|
||||
}
|
||||
@@ -6699,8 +6817,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
* part of the scrollback in between. The mobile retry asks every 30 seconds,
|
||||
* so that happened on a timer for as long as the refusal lasted. So the
|
||||
* columns stay at the PTY's (the #464 invariant: the browser never draws at
|
||||
* a width the PTY does not have), the rows follow the container (they are
|
||||
* never adopted, see reconcilePtyGeometry), and the container's columns go
|
||||
* a width the PTY does not have), the rows follow the container or the
|
||||
* keyboard hold (they are never adopted, see reconcilePtyGeometry and
|
||||
* getTerminalDimensions), and the container's columns go
|
||||
* out as the request. An accepted request is adopted by the report.
|
||||
*
|
||||
* @returns {{cols: number, rows: number}|null} the geometry to request
|
||||
|
||||
@@ -1976,6 +1976,16 @@ export class WebServer extends EventEmitter {
|
||||
'</head>',
|
||||
() => `<script>window.__codemanTranscriptPageKeys=${JSON.stringify(pageKeyClis)};</script>\n</head>`
|
||||
);
|
||||
// Which run modes keep their PTY rows under a phone keyboard (inline renderers,
|
||||
// the `keyboardHold` capability; `_keyboardHoldEligible` in terminal-ui.js). Same
|
||||
// shape and reasoning as the page-key list above.
|
||||
const keyboardHoldClis = enabledClis()
|
||||
.filter((entry) => entry.capabilities.keyboardHold === 'inline')
|
||||
.map((entry) => entry.id);
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
() => `<script>window.__codemanKeyboardHoldModes=${JSON.stringify(keyboardHoldClis)};</script>\n</head>`
|
||||
);
|
||||
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
|
||||
html = html.replace('</head>', () => `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||
if (settings.gestureControlEnabled === true) {
|
||||
|
||||
Reference in new issue
Block a user