From 9b9f2c21e9ebcbb1a7f4cce200aae56dab4ac01a Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 10 Aug 2026 03:09:44 +0200 Subject: [PATCH 1/3] feat(mobile): shell keyboard bar with a one-shot Ctrl modifier (#262) The mobile accessory bar was built around coding-agent commands, so a shell session had no way to send Ctrl chords at all. A shell-mode session now gets its own bar automatically: Ctrl, Esc, Tab, four arrows, paste, dismiss. Agent sessions (claude, codex, opencode, gemini, antigravity) keep the existing bar unchanged. Ctrl is a one-shot modifier: tap it and it lights up, the next character typed on the system keyboard is sent as its control byte, and Ctrl disarms. Tapping it again cancels. That puts Ctrl+C/D/Z/R/L/A/E/W/U/K on a nine-button bar without a button per chord. Implementation notes: * The interception lives in terminal.onData, not a keydown handler: a virtual keyboard reports no usable key events, so the character only exists as onData text. It sits after shouldSuppressTerminalQueryResponse (xterm answers DA/CPR queries through onData too, and letting one of those spend the modifier would silently eat the user's Ctrl) and before every send path, so the control byte follows the normal control-char route. * ctrlByteFor() maps `code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space = NUL and Ctrl+? = DEL. Characters with no control equivalent pass through unchanged, like a hardware keyboard. * The bar now separates the base layout (the extendedKeyboardBar setting) from the effective one, resolved per session by refreshForActiveSession(). A settings save during a shell session cannot yank the bar away, and switching back to an agent tab restores the user's choice. * Ctrl disarms on use, a second tap, any other accessory key, a session switch, keyboard dismissal and a layout swap. * Ctrl joins the refocus set, so tapping it keeps the terminal focused and the keyboard open. * The armed style needs three classes to outrank mobile.css's light-skin .accessory-btn rule at (0,2,1). Verified end to end against a real shell session on an isolated instance: tapping Ctrl then typing c interrupted a running `sleep 300` (^C in the pane), the modifier disarmed, plain typing stayed literal, Ctrl+L cleared, and a cancelled Ctrl typed a literal c. Tests: test/mobile-shell-keyboard.test.ts (new, runs in CI) covers the mapping table, layout selection per session mode, base-mode memory and every disarm path; test/mobile/keyboard.test.ts adds nine browser regressions that drive the real xterm with page.keyboard.type() and assert on the bytes that would go out. Closes #262 Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 + src/web/public/app.js | 10 +- src/web/public/keyboard-accessory.js | 191 ++++++++++++++- src/web/public/mobile.css | 11 + src/web/public/styles.css | 12 + src/web/public/terminal-ui.js | 12 + test/mobile-shell-keyboard.test.ts | 344 +++++++++++++++++++++++++++ test/mobile/keyboard.test.ts | 153 ++++++++++++ 8 files changed, 728 insertions(+), 7 deletions(-) create mode 100644 test/mobile-shell-keyboard.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index e6792be6..ed07ddac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -270,6 +270,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **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** (issue #262, `keyboard-accessory.js`): a **shell**-mode session automatically swaps the mobile accessory bar for terminal controls (Ctrl, Esc, Tab, four arrows, paste, dismiss); every other mode keeps the agent bar. `setMode()` now records the user's `extendedKeyboardBar` preference as the **base** layout and `refreshForActiveSession()` (called from `selectSession`) resolves base-vs-shell, so a settings save during a shell session cannot yank the bar away and switching back restores the user's choice. ⚠️ **Ctrl is a ONE-SHOT modifier applied in `terminal.onData`, not in a keydown handler**: a virtual keyboard emits no usable key events, so the character only exists as onData text. The hook sits AFTER `shouldSuppressTerminalQueryResponse` (xterm answers DA/CPR through onData too, and one of those would silently spend the modifier) and BEFORE every send path, so the control byte follows the normal control-char route. ⚠️ It must disarm on ALL of: use, second tap, any other accessory key, session switch, keyboard dismissal, and a layout swap; a modifier left armed turns the next innocent keystroke into a control byte. Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. The armed style needs **three classes** (`.accessory-btn.accessory-btn-ctrl.armed`) to outrank mobile.css's light-skin `.accessory-btn` rule at (0,2,1). + **Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 430px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged. ⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input. diff --git a/src/web/public/app.js b/src/web/public/app.js index f475cb67..66f76cde 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -848,9 +848,11 @@ class CodemanApp { SwipeHandler.init(); VoiceInput.init(); KeyboardAccessoryBar.init(); - // Apply keyboard bar mode from settings + // Apply keyboard bar mode from settings. Always set it (not only when the + // extended bar is on) so the bar's remembered agent-session layout matches + // the setting before the first shell session swaps in the terminal bar. const _kbSettings = this.loadAppSettingsFromStorage(); - if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended'); + KeyboardAccessoryBar.setMode(_kbSettings.extendedKeyboardBar ? 'extended' : 'simple'); this.applyHeaderVisibilitySettings(); this.restorePlanUsageChip(); this.applySkin(); @@ -4457,6 +4459,10 @@ class CodemanApp { this.loadAttachmentHistory?.(sessionId); } this._updateLocalEchoState(); + // Shell sessions get the terminal keyboard bar, agent sessions the command + // bar (issue #262). Also disarms a one-shot Ctrl left over from the tab we + // just left, so it can never fire against the session we just opened. + if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.refreshForActiveSession(); // Restore flushed offset AND text IMMEDIATELY so backspace/typing work during // the async buffer load. Without this, the offset is 0 during the diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index a254b96c..676598a9 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -12,6 +12,13 @@ * Destructive actions (/clear, /compact, extended bar only) require double-tap confirmation (2s amber state). * Commands are sent as text + Enter separately for Ink compatibility. * Only initializes on touch devices (MobileDetection.isTouchDevice guard). + * SHELL sessions get their own layout automatically (issue #262): Ctrl, Esc, Tab, + * four arrows, paste, dismiss. Ctrl is a ONE-SHOT modifier: arm it, type a + * character on the system keyboard, and terminal-ui.js's onData hook swaps the + * character for its control byte (ctrlByteFor) and disarms. That is what makes + * Ctrl+C/D/Z/R/L/A/E/W/U/K reachable without a button per chord. It resets on + * use, on a second tap, on any other accessory key, on a session switch + * (refreshForActiveSession) and when the keyboard is dismissed (hide). * - PathPicker (singleton object) — Lazy server-side file/folder browser shared * by Link Existing and the extended mobile keyboard bar. * @@ -414,12 +421,58 @@ const PathPicker = { // Mobile Keyboard Accessory Bar // ═══════════════════════════════════════════════════════════════ +/** + * Control byte a terminal sends for Ctrl+ (issue #262). + * + * Returns null for characters with no control equivalent (digits, most + * punctuation): the caller then sends the character unchanged, matching a + * hardware keyboard where Ctrl+7 just types "7". + * + * `code & 0x1f` covers both ranges a terminal maps: @A-Z[\]^_ (64-95 → 0-31) + * and a-z (97-122 → 1-26). Space and ? are the two conventional extras + * (Ctrl+Space = NUL, Ctrl+? = DEL) and can't come from the mask. + */ +function ctrlByteFor(char) { + if (typeof char !== 'string' || char.length !== 1) return null; + const code = char.charCodeAt(0); + if (code === 32) return '\x00'; + if (code === 63) return '\x7f'; + if ((code >= 64 && code <= 95) || (code >= 97 && code <= 122)) { + return String.fromCharCode(code & 0x1f); + } + return null; +} + +/** + * Apply an armed one-shot Ctrl to one chunk of terminal input. + * Returns `{ data, consumed }`, where `consumed` tells the bar to disarm. + * + * Multi-character chunks (pastes, escape sequences, IME commits) have no + * single key to modify, but they still spend the modifier: leaving it armed + * would silently turn the NEXT innocent keystroke into a control byte. + */ +function applyOneShotCtrl(data) { + if (typeof data !== 'string' || data.length === 0) return { data, consumed: false }; + if (data.length === 1) { + const byte = ctrlByteFor(data); + return { data: byte === null ? data : byte, consumed: true }; + } + return { data, consumed: true }; +} + /** * KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing. */ const KeyboardAccessoryBar = { element: null, - _mode: 'simple', // 'simple' or 'extended' + // Layout currently in the DOM: 'simple' | 'extended' | 'shell'. + _mode: 'simple', + // Layout the user picked for AGENT sessions ('simple' | 'extended', the + // extendedKeyboardBar setting). Shell sessions override it with the shell + // bar; this is what we come back to when they switch to an agent tab. + _baseMode: 'simple', + // One-shot Ctrl modifier (shell bar only). See handleAction('ctrl'). + _ctrlArmed: false, /** HTML for simple mode: arrows, commands, paste, Esc, dismiss */ _simpleButtons: ` @@ -448,6 +501,45 @@ const KeyboardAccessoryBar = { `, + /** HTML for shell mode (issue #262): terminal controls instead of agent + * commands. Ctrl is a one-shot modifier rather than one button per chord, + * which is what puts Ctrl+C/D/Z/R/L/A/E/W/U/K on a 9-button bar. */ + _shellButtons: ` + + + + + + + + + `, + /** HTML for extended mode: all keys including arrows, Tab, Esc, etc. */ _extendedButtons: `