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: `