Comprehensive set of mobile and all improvements phase 1

This commit is contained in:
gsxdsm
2026-02-17 17:33:11 -08:00
parent 7fcf3c1e1f
commit cb44f8a717
36 changed files with 2037 additions and 304 deletions

View File

@@ -21,8 +21,6 @@ const SPECIAL_KEYS = {
const CTRL_KEYS = {
'Ctrl+C': '\x03', // Interrupt / SIGINT
'Ctrl+Z': '\x1a', // Suspend / SIGTSTP
'Ctrl+D': '\x04', // EOF
'Ctrl+L': '\x0c', // Clear screen
'Ctrl+A': '\x01', // Move to beginning of line
'Ctrl+B': '\x02', // Move cursor back (tmux prefix)
} as const;
@@ -34,7 +32,7 @@ const ARROW_KEYS = {
left: '\x1b[D',
} as const;
interface MobileTerminalControlsProps {
interface MobileTerminalShortcutsProps {
/** Callback to send input data to the terminal WebSocket */
onSendInput: (data: string) => void;
/** Whether the terminal is connected and ready */
@@ -42,14 +40,17 @@ interface MobileTerminalControlsProps {
}
/**
* Mobile quick controls bar for terminal interaction on touch devices.
* Mobile shortcuts bar for terminal interaction on touch devices.
* Provides special keys (Escape, Tab, Ctrl+C, etc.) and arrow keys that are
* typically unavailable on mobile virtual keyboards.
*
* Anchored at the top of the terminal panel, above the terminal content.
* Can be collapsed to a minimal toggle to maximize terminal space.
*/
export function MobileTerminalControls({ onSendInput, isConnected }: MobileTerminalControlsProps) {
export function MobileTerminalShortcuts({
onSendInput,
isConnected,
}: MobileTerminalShortcutsProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
// Track repeat interval for arrow key long-press
@@ -108,10 +109,10 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
<button
className="flex items-center gap-1 px-4 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors touch-manipulation"
onClick={() => setIsCollapsed(false)}
title="Show quick controls"
title="Show shortcuts"
>
<ChevronDown className="h-3.5 w-3.5" />
<span>Controls</span>
<span>Shortcuts</span>
</button>
</div>
);
@@ -123,7 +124,7 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
<button
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0 touch-manipulation"
onClick={() => setIsCollapsed(true)}
title="Hide quick controls"
title="Hide shortcuts"
>
<ChevronUp className="h-4 w-4" />
</button>
@@ -132,12 +133,12 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
<div className="w-px h-6 bg-border shrink-0" />
{/* Special keys */}
<ControlButton
<ShortcutButton
label="Esc"
onPress={() => sendKey(SPECIAL_KEYS.escape)}
disabled={!isConnected}
/>
<ControlButton
<ShortcutButton
label="Tab"
onPress={() => sendKey(SPECIAL_KEYS.tab)}
disabled={!isConnected}
@@ -147,31 +148,19 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
<div className="w-px h-6 bg-border shrink-0" />
{/* Common Ctrl shortcuts */}
<ControlButton
<ShortcutButton
label="^C"
title="Ctrl+C (Interrupt)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+C'])}
disabled={!isConnected}
/>
<ControlButton
<ShortcutButton
label="^Z"
title="Ctrl+Z (Suspend)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+Z'])}
disabled={!isConnected}
/>
<ControlButton
label="^D"
title="Ctrl+D (EOF)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+D'])}
disabled={!isConnected}
/>
<ControlButton
label="^L"
title="Ctrl+L (Clear)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+L'])}
disabled={!isConnected}
/>
<ControlButton
<ShortcutButton
label="^B"
title="Ctrl+B (Back/tmux prefix)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+B'])}
@@ -181,26 +170,6 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
{/* Separator */}
<div className="w-px h-6 bg-border shrink-0" />
{/* Navigation keys */}
<ControlButton
label="Del"
onPress={() => sendKey(SPECIAL_KEYS.delete)}
disabled={!isConnected}
/>
<ControlButton
label="Home"
onPress={() => sendKey(SPECIAL_KEYS.home)}
disabled={!isConnected}
/>
<ControlButton
label="End"
onPress={() => sendKey(SPECIAL_KEYS.end)}
disabled={!isConnected}
/>
{/* Separator */}
<div className="w-px h-6 bg-border shrink-0" />
{/* Arrow keys with long-press repeat */}
<ArrowButton
direction="left"
@@ -226,14 +195,34 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
onRelease={handleArrowRelease}
disabled={!isConnected}
/>
{/* Separator */}
<div className="w-px h-6 bg-border shrink-0" />
{/* Navigation keys */}
<ShortcutButton
label="Del"
onPress={() => sendKey(SPECIAL_KEYS.delete)}
disabled={!isConnected}
/>
<ShortcutButton
label="Home"
onPress={() => sendKey(SPECIAL_KEYS.home)}
disabled={!isConnected}
/>
<ShortcutButton
label="End"
onPress={() => sendKey(SPECIAL_KEYS.end)}
disabled={!isConnected}
/>
</div>
);
}
/**
* Individual control button for special keys and shortcuts.
* Individual shortcut button for special keys.
*/
function ControlButton({
function ShortcutButton({
label,
title,
onPress,

View File

@@ -0,0 +1,147 @@
import { useCallback } from 'react';
import { cn } from '@/lib/utils';
export type StickyModifier = 'ctrl' | 'alt' | null;
interface StickyModifierKeysProps {
/** Currently active sticky modifier (null = none) */
activeModifier: StickyModifier;
/** Callback when a modifier is toggled */
onModifierChange: (modifier: StickyModifier) => void;
/** Whether the terminal is connected */
isConnected: boolean;
}
/**
* Sticky modifier keys (Ctrl, Alt) for the terminal toolbar.
*
* "Sticky" means: tap a modifier to activate it, then the next key pressed
* in the terminal will be sent with that modifier applied. After the modified
* key is sent, the sticky modifier automatically deactivates.
*
* - Ctrl: Sends the control code (character code & 0x1f)
* - Alt: Sends escape prefix (\x1b) before the character
*
* Tapping an already-active modifier deactivates it (toggle behavior).
*/
export function StickyModifierKeys({
activeModifier,
onModifierChange,
isConnected,
}: StickyModifierKeysProps) {
const toggleCtrl = useCallback(() => {
onModifierChange(activeModifier === 'ctrl' ? null : 'ctrl');
}, [activeModifier, onModifierChange]);
const toggleAlt = useCallback(() => {
onModifierChange(activeModifier === 'alt' ? null : 'alt');
}, [activeModifier, onModifierChange]);
return (
<div className="flex items-center gap-1 shrink-0">
<ModifierButton
label="Ctrl"
isActive={activeModifier === 'ctrl'}
onPress={toggleCtrl}
disabled={!isConnected}
title="Sticky Ctrl tap to activate, then press a key (e.g. Ctrl+C)"
/>
<ModifierButton
label="Alt"
isActive={activeModifier === 'alt'}
onPress={toggleAlt}
disabled={!isConnected}
title="Sticky Alt tap to activate, then press a key (e.g. Alt+D)"
/>
</div>
);
}
/**
* Individual modifier toggle button with active state styling.
*/
function ModifierButton({
label,
isActive,
onPress,
disabled = false,
title,
}: {
label: string;
isActive: boolean;
onPress: () => void;
disabled?: boolean;
title?: string;
}) {
return (
<button
className={cn(
'px-2 py-1 rounded-md text-xs font-medium shrink-0 select-none transition-all min-w-[36px] min-h-[28px] flex items-center justify-center',
'touch-manipulation border',
isActive
? 'bg-brand-500 text-white border-brand-500 shadow-sm shadow-brand-500/25'
: 'bg-muted/80 text-foreground hover:bg-accent border-transparent',
disabled && 'opacity-40 pointer-events-none'
)}
onPointerDown={(e) => {
e.preventDefault(); // Prevent focus stealing from terminal
onPress();
}}
title={title}
disabled={disabled}
aria-pressed={isActive}
role="switch"
>
{label}
</button>
);
}
/**
* Apply a sticky modifier to raw terminal input data.
*
* For Ctrl: converts printable ASCII characters to their control-code equivalent.
* e.g. 'c' → \x03 (Ctrl+C), 'a' → \x01 (Ctrl+A)
*
* For Alt: prepends the escape character (\x1b) before the data.
* e.g. 'd' → \x1bd (Alt+D)
*
* Returns null if the modifier cannot be applied (non-ASCII, etc.)
*/
export function applyStickyModifier(data: string, modifier: StickyModifier): string | null {
if (!modifier || !data) return null;
if (modifier === 'ctrl') {
// Only apply Ctrl to single printable ASCII characters (a-z, A-Z, and some specials)
if (data.length === 1) {
const code = data.charCodeAt(0);
// Letters a-z or A-Z: Ctrl sends code & 0x1f
if ((code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a)) {
return String.fromCharCode(code & 0x1f);
}
// Special Ctrl combinations
// Ctrl+[ = Escape (0x1b)
if (code === 0x5b) return '\x1b';
// Ctrl+\ = 0x1c
if (code === 0x5c) return '\x1c';
// Ctrl+] = 0x1d
if (code === 0x5d) return '\x1d';
// Ctrl+^ = 0x1e
if (code === 0x5e) return '\x1e';
// Ctrl+_ = 0x1f
if (code === 0x5f) return '\x1f';
// Ctrl+Space or Ctrl+@ = 0x00 (NUL)
if (code === 0x20 || code === 0x40) return '\x00';
}
return null;
}
if (modifier === 'alt') {
// Alt sends ESC prefix followed by the character
return '\x1b' + data;
}
return null;
}

View File

@@ -53,7 +53,12 @@ import { getElectronAPI } from '@/lib/electron';
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query';
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
import { MobileTerminalControls } from './mobile-terminal-controls';
import { MobileTerminalShortcuts } from './mobile-terminal-shortcuts';
import {
StickyModifierKeys,
applyStickyModifier,
type StickyModifier,
} from './sticky-modifier-keys';
const logger = createLogger('Terminal');
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
@@ -158,6 +163,10 @@ export function TerminalPanel({
const showSearchRef = useRef(false);
const [isAtBottom, setIsAtBottom] = useState(true);
// Sticky modifier key state (Ctrl or Alt) for the terminal toolbar
const [stickyModifier, setStickyModifier] = useState<StickyModifier>(null);
const stickyModifierRef = useRef<StickyModifier>(null);
const [connectionStatus, setConnectionStatus] = useState<
'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'auth_failed'
>('connecting');
@@ -166,7 +175,7 @@ export function TerminalPanel({
const INITIAL_RECONNECT_DELAY = 1000;
const [processExitCode, setProcessExitCode] = useState<number | null>(null);
// Detect mobile viewport for quick controls
// Detect mobile viewport for shortcuts bar
const isMobile = useIsMobile();
// Track virtual keyboard height on mobile to prevent overlap
@@ -354,7 +363,13 @@ export function TerminalPanel({
}
}, []);
// Send raw input to terminal via WebSocket (used by mobile quick controls)
// Handle sticky modifier toggle and keep ref in sync
const handleStickyModifierChange = useCallback((modifier: StickyModifier) => {
setStickyModifier(modifier);
stickyModifierRef.current = modifier;
}, []);
// Send raw input to terminal via WebSocket (used by mobile shortcuts bar)
const sendTerminalInput = useCallback((data: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'input', data }));
@@ -1207,10 +1222,24 @@ export function TerminalPanel({
connect();
// Handle terminal input
// Handle terminal input - apply sticky modifier if active
const dataHandler = terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'input', data }));
const modifier = stickyModifierRef.current;
if (modifier) {
const modified = applyStickyModifier(data, modifier);
if (modified !== null) {
wsRef.current.send(JSON.stringify({ type: 'input', data: modified }));
} else {
// Could not apply modifier (e.g. non-ASCII input), send as-is
wsRef.current.send(JSON.stringify({ type: 'input', data }));
}
// Clear sticky modifier after one key press (one-shot behavior)
stickyModifierRef.current = null;
setStickyModifier(null);
} else {
wsRef.current.send(JSON.stringify({ type: 'input', data }));
}
}
});
@@ -2037,6 +2066,15 @@ export function TerminalPanel({
<div className="w-px h-3 mx-0.5 bg-border" />
{/* Sticky modifier keys (Ctrl, Alt) */}
<StickyModifierKeys
activeModifier={stickyModifier}
onModifierChange={handleStickyModifierChange}
isConnected={connectionStatus === 'connected'}
/>
<div className="w-px h-3 mx-0.5 bg-border" />
{/* Split/close buttons */}
<Button
variant="ghost"
@@ -2157,9 +2195,9 @@ export function TerminalPanel({
</div>
)}
{/* Mobile quick controls - special keys and arrow keys for touch devices */}
{/* Mobile shortcuts bar - special keys and arrow keys for touch devices */}
{isMobile && (
<MobileTerminalControls
<MobileTerminalShortcuts
onSendInput={sendTerminalInput}
isConnected={connectionStatus === 'connected'}
/>