feat: add Shift modifier key and fix keyboard toggle behavior

- Add Shift modifier button (⇧) alongside Ctrl/Alt with tap/double-tap lock support
- Server-side Shift key parsing (Shift-Tab → shift+tab, etc.)
- Keyboard toggle now only hides key rows, keeping text input always visible
- Fix shortcut-keys row: horizontal scroll instead of wrapping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent 8bcd7eacd8
commit 239c6d2ef6

@ -186,11 +186,15 @@
}
}
.key-row.shortcut-keys { flex-wrap: wrap; }
.key-row.shortcut-keys {
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.key-row.shortcut-keys::-webkit-scrollbar { display: none; }
.key-row.shortcut-keys .key.shortcut { min-width: 32px; padding: 0 5px; }
.keyboard-toggle { display: none; }
@media (min-width: 1024px) {
.keyboard-toggle {
display: flex; align-items: center; justify-content: center; gap: 6px;
width: 100%; height: 28px;
@ -200,5 +204,4 @@
cursor: pointer; flex-shrink: 0;
}
.keyboard-toggle:hover { color: var(--text-secondary); background: var(--bg-tertiary); }
.virtual-keyboard.collapsed { display: none; }
}
.virtual-keyboard.collapsed .key-row:not(.input-row) { display: none; }

@ -33,7 +33,6 @@
<span class="app-title">cmux-remote</span>
<div class="top-bar-right">
<span class="connection-status" id="connection-status">Offline</span>
<button class="qr-btn" id="qr-btn" aria-label="Show QR code">&#x2337;</button>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
</div>
</header>
@ -44,13 +43,14 @@
</main>
<!-- Virtual keyboard -->
<button class="keyboard-toggle" id="keyboard-toggle" aria-label="Toggle keyboard" hidden>&#x2328; Keyboard</button>
<button class="keyboard-toggle" id="keyboard-toggle" aria-label="Toggle keyboard">&#x2328; Keyboard</button>
<footer class="virtual-keyboard" id="virtual-keyboard">
<div class="key-row special-keys">
<button class="key" data-key="Escape">Esc</button>
<button class="key" data-key="Tab">Tab</button>
<button class="key modifier" data-modifier="ctrl" id="ctrl-key">Ctrl</button>
<button class="key modifier" data-modifier="alt" id="alt-key">Alt</button>
<button class="key modifier" data-modifier="shift" id="shift-key">&#x21E7;</button>
<span class="key-spacer"></span>
<button class="key" data-key="ArrowUp">&uarr;</button>
<button class="key" data-key="PageUp">PgUp</button>
@ -66,7 +66,6 @@
<button class="key shortcut" data-key="Ctrl-r">^R</button>
<button class="key shortcut" data-key="Ctrl-w">^W</button>
<button class="key shortcut" data-key="Ctrl-u">^U</button>
<span class="key-spacer"></span>
<button class="key" data-key="ArrowLeft">&larr;</button>
<button class="key" data-key="ArrowDown">&darr;</button>
<button class="key" data-key="ArrowRight">&rarr;</button>
@ -79,15 +78,6 @@
</footer>
</div>
<!-- QR modal -->
<div class="qr-modal-overlay" id="qr-modal-overlay" hidden>
<div class="qr-modal" role="dialog" aria-modal="true" aria-label="QR Code">
<button class="qr-modal-close" id="qr-modal-close" aria-label="Close">&times;</button>
<img class="qr-modal-image" src="/api/qr.svg" alt="QR code for access URL" id="qr-modal-image">
<div class="qr-modal-url" id="qr-modal-url"></div>
</div>
</div>
<script src="/js/websocket-client.js"></script>
<script src="/js/terminal-view.js"></script>
<script src="/js/virtual-keyboard.js"></script>

@ -6,6 +6,8 @@ class VirtualKeyboard {
this.ctrlLocked = false;
this.altActive = false;
this.altLocked = false;
this.shiftActive = false;
this.shiftLocked = false;
this._repeatTimer = null;
this._repeatInterval = null;
@ -13,6 +15,7 @@ class VirtualKeyboard {
this.sendBtn = document.getElementById('send-btn');
this.ctrlKey = document.getElementById('ctrl-key');
this.altKey = document.getElementById('alt-key');
this.shiftKey = document.getElementById('shift-key');
this.init();
}
@ -42,6 +45,10 @@ class VirtualKeyboard {
this.altKey.addEventListener('click', () => this.toggleModifier('alt'));
this.altKey.addEventListener('dblclick', () => this.lockModifier('alt'));
// Shift modifier
this.shiftKey.addEventListener('click', () => this.toggleModifier('shift'));
this.shiftKey.addEventListener('dblclick', () => this.lockModifier('shift'));
// Special keys
document.querySelectorAll('.key[data-key]').forEach((btn) => {
if (btn.classList.contains('modifier')) return;
@ -94,20 +101,24 @@ class VirtualKeyboard {
handleKeyPress(key) {
this._haptic();
// Check for shortcut keys like Ctrl-c
if (key.startsWith('Ctrl-')) {
// Check for pre-built shortcut keys like Ctrl-c
if (key.startsWith('Ctrl-') || key.startsWith('Alt-') || key.startsWith('Shift-')) {
this.onSendKey(key);
return;
}
// Apply active modifiers
let finalKey = key;
if (this.shiftActive) {
finalKey = `Shift-${finalKey}`;
if (!this.shiftLocked) this.deactivateModifier('shift');
}
if (this.ctrlActive) {
finalKey = `Ctrl-${key}`;
finalKey = `Ctrl-${finalKey}`;
if (!this.ctrlLocked) this.deactivateModifier('ctrl');
}
if (this.altActive) {
finalKey = `Alt-${key}`;
finalKey = `Alt-${finalKey}`;
if (!this.altLocked) this.deactivateModifier('alt');
}
@ -117,19 +128,17 @@ class VirtualKeyboard {
toggleModifier(mod) {
this._haptic();
if (mod === 'ctrl') {
if (this.ctrlLocked) {
this.deactivateModifier('ctrl');
return;
}
if (this.ctrlLocked) { this.deactivateModifier('ctrl'); return; }
this.ctrlActive = !this.ctrlActive;
this.ctrlKey.classList.toggle('active', this.ctrlActive);
} else if (mod === 'alt') {
if (this.altLocked) {
this.deactivateModifier('alt');
return;
}
if (this.altLocked) { this.deactivateModifier('alt'); return; }
this.altActive = !this.altActive;
this.altKey.classList.toggle('active', this.altActive);
} else if (mod === 'shift') {
if (this.shiftLocked) { this.deactivateModifier('shift'); return; }
this.shiftActive = !this.shiftActive;
this.shiftKey.classList.toggle('active', this.shiftActive);
}
}
@ -142,6 +151,10 @@ class VirtualKeyboard {
this.altActive = true;
this.altLocked = true;
this.altKey.classList.add('active', 'locked');
} else if (mod === 'shift') {
this.shiftActive = true;
this.shiftLocked = true;
this.shiftKey.classList.add('active', 'locked');
}
}
@ -154,6 +167,10 @@ class VirtualKeyboard {
this.altActive = false;
this.altLocked = false;
this.altKey.classList.remove('active', 'locked');
} else if (mod === 'shift') {
this.shiftActive = false;
this.shiftLocked = false;
this.shiftKey.classList.remove('active', 'locked');
}
}

@ -46,6 +46,15 @@ export class InputHandler {
return;
}
// Handle Shift+key combos
const shiftMatch = key.match(/^Shift[+-](.+)$/i);
if (shiftMatch) {
const inner = shiftMatch[1];
const mapped = KEY_MAP[inner] || inner.toLowerCase();
await this.client.sendKey(workspace, surface, `shift+${mapped}`);
return;
}
// Map known keys
const mapped = KEY_MAP[key] || key;
await this.client.sendKey(workspace, surface, mapped);

Loading…
Cancel
Save