From 659ccd001b58ae2baf352b363d718d2a6eb0c36d Mon Sep 17 00:00:00 2001 From: arkon Date: Mon, 16 Feb 2026 13:56:47 +0100 Subject: [PATCH] fix: mobile keyboard hides terminal content when typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send SIGWINCH to server on keyboard open/close so Ink renders at the correct terminal dimensions. Previously only local xterm was resized while server resize was suppressed, causing Ink to redraw at the old (larger) row count on each keystroke — pushing content off screen. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- package.json | 2 +- src/web/public/app.js | 72 +++++++++++++++++++++++++++++++++++---- src/web/public/mobile.css | 11 ++++++ 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2d62e9d4..cfb35319 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1512 (must match `package.json` for npm publish) +**Version**: 0.1513 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index 02d31b80..8b993a82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1512", + "version": "0.1513", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/web/public/app.js b/src/web/public/app.js index c2e77bc6..2f52806f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -342,13 +342,18 @@ const KeyboardHandler = { KeyboardAccessoryBar.show(); } - // Scroll active terminal to bottom so input line is visible - // Delay to let layout settle after keyboard resizes the viewport + // Refit terminal locally AND send resize to server so Claude Code (Ink) + // knows the actual terminal dimensions. Without this, Ink redraws at the + // old (larger) row count when the user types, causing content to scroll + // off the visible area with each keystroke. + // Note: the throttledResize handler still suppresses ongoing resize events + // while keyboard is up — this one-shot resize on open/close is sufficient. setTimeout(() => { if (typeof app !== 'undefined' && app.terminal) { - // Refit terminal to the now-smaller container, then scroll if (app.fitAddon) try { app.fitAddon.fit(); } catch {} app.terminal.scrollToBottom(); + // Send resize to server so PTY dimensions match xterm + this._sendTerminalResize(); } }, 150); }, @@ -362,14 +367,34 @@ const KeyboardHandler = { this.resetLayout(); - // Refit terminal to the restored (larger) container to prevent black gap + // Refit terminal and send resize to restore original dimensions setTimeout(() => { if (typeof app !== 'undefined' && app.fitAddon) { try { app.fitAddon.fit(); } catch {} + // Send resize to server to restore full terminal size + this._sendTerminalResize(); } }, 100); }, + /** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */ + _sendTerminalResize() { + if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return; + try { + const dims = app.fitAddon.proposeDimensions(); + if (dims) { + const cols = Math.max(dims.cols, 40); + const rows = Math.max(dims.rows, 10); + app._lastResizeDims = { cols, rows }; + fetch(`/api/sessions/${app.activeSessionId}/resize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cols, rows }) + }); + } + } catch {} + }, + /** Check if element is an input that triggers keyboard (excludes terminal) */ isInputElement(el) { if (!el) return false; @@ -500,6 +525,16 @@ const KeyboardAccessoryBar = { this.element = document.createElement('div'); this.element.className = 'keyboard-accessory-bar'; this.element.innerHTML = ` + + @@ -526,8 +561,9 @@ const KeyboardAccessoryBar = { const action = btn.dataset.action; this.handleAction(action, btn); - // For double-tap buttons, refocus terminal so keyboard stays open during confirm state - if ((action === 'clear' || action === 'compact') && this._confirmAction) { + // Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts) + if ((action === 'scroll-up' || action === 'scroll-down') || + ((action === 'clear' || action === 'compact') && this._confirmAction)) { if (typeof app !== 'undefined' && app.terminal) { app.terminal.focus(); } @@ -549,6 +585,12 @@ const KeyboardAccessoryBar = { if (typeof app === 'undefined' || !app.activeSessionId) return; switch (action) { + case 'scroll-up': + this.sendKey('\x1b[A'); + break; + case 'scroll-down': + this.sendKey('\x1b[B'); + break; case 'init': this.sendCommand('/init'); break; @@ -613,6 +655,18 @@ const KeyboardAccessoryBar = { setTimeout(() => app.sendInput('\r'), 120); }, + /** Send a special key (arrow, escape, etc.) directly to the PTY. + * Bypasses tmux send-keys -l (literal mode) since escape sequences + * must be written raw to be interpreted as key presses by Ink. */ + sendKey(escapeSequence) { + if (!app.activeSessionId) return; + fetch(`/api/sessions/${app.activeSessionId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: escapeSequence }) + }); + }, + /** Read clipboard and send contents as input */ /** Show a paste overlay with a textarea for iOS compatibility */ pasteFromClipboard() { @@ -1590,7 +1644,11 @@ class ClaudemanApp { this._resizeTimeout = null; if (this.fitAddon) { this.fitAddon.fit(); - if (this.activeSessionId) { + // Skip server resize while mobile keyboard is visible — sending SIGWINCH + // causes Ink to re-render at the new row count, garbling terminal output. + // Local fit() still runs so xterm knows the viewport size for scrolling. + const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; + if (this.activeSessionId && !keyboardUp) { const dims = this.fitAddon.proposeDimensions(); // Enforce minimum dimensions to prevent layout issues const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS; diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 81607b08..9bc17e83 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -526,6 +526,17 @@ height: 14px; } + .accessory-btn-arrow { + padding: 6px 10px; + background: #1e3a5f; + border-color: rgba(59, 130, 246, 0.3); + color: #93c5fd; + } + + .accessory-btn-arrow:active { + background: #2563eb; + } + .accessory-btn-dismiss { margin-left: auto; padding: 6px 10px;