mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-19 10:43:08 +00:00
Comprehensive set of mobile and all improvements phase 1
This commit is contained in:
@@ -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,
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user