diff --git a/CLAUDE.md b/CLAUDE.md index 35e290c2..e203d413 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,6 +276,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. ⚠️ **Not every onData chunk is a keystroke**, and the query filter is not enough on its own: xterm ALSO emits mouse and focus reports on its own initiative, so the hook skips them via `isTerminalFocusOrMouseReport()` (they still reach the PTY, they just don't count as the next key). The mouse half is live — a shell session keeps the NARROW strip, so mouse DECSETs reach the browser and one tap while vim/htop runs spent the armed modifier silently (measured). The focus half is defense in depth: `FOCUS_ESCAPE_FILTER` in `session.ts` strips `\x1b[?1004h` from every PTY read, so `sendFocusMode` never turns on today; if it ever did, the bar's own post-key refocus would emit `\x1b[I` and eat the modifier before the user typed. ⚠️ 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. ⚠️ **onData is not the only input path** — with `cjkInputEnabled` on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends `terminal.focus()` there, which is where the bar refocuses after every key), so `_handleCjkInput()` applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke. 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 is `.accessory-btn.accessory-btn-ctrl.armed` (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at **(0,3,1)** (`:is()` inherits its most specific argument, and that list holds `.btn-toolbar.btn-shell`) — so that rule excludes the state by hand as `.accessory-btn:not(.armed)`. Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all. + **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 9b633c20..8c93ea47 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(); @@ -4531,6 +4533,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: `