Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions src/client/__tests__/terminalControls.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { afterEach, describe, expect, test } from 'bun:test'
import TestRenderer, { act } from 'react-test-renderer'
import NumPad from '../components/NumPad'

const globalAny = globalThis as typeof globalThis & {
navigator?: Navigator
Expand All @@ -17,14 +16,9 @@ afterEach(() => {

function findPasteButton(renderer: TestRenderer.ReactTestRenderer) {
const buttons = renderer.root.findAllByType('button')
return buttons.find((button) => {
const child = button.props.children
return (
child?.type === 'svg' &&
child.props?.stroke === 'currentColor' &&
child.props?.fill === 'none'
)
})
return buttons.find((button) =>
button.props['aria-label'] === 'Paste'
)
}

describe('TerminalControls', () => {
Expand Down Expand Up @@ -53,19 +47,27 @@ describe('TerminalControls', () => {
ctrlButton.props.onClick()
})

const numpad = renderer.root.findByType(NumPad)
// Find the mode toggle button (sends Shift+Tab) and use it to test ctrl modifier
const modeButton = renderer.root.findAllByType('button').find(
(button) => button.props['aria-label'] === 'Toggle mode (Shift+Tab)'
)
if (!modeButton) {
throw new Error('Expected mode toggle button')
}

act(() => {
numpad.props.onSendKey('a')
modeButton.props.onClick()
})

expect(sent[0]).toBe(String.fromCharCode(1))
// Ctrl + Shift+Tab sends the raw escape sequence (non-letter, ctrl consumed)
expect(sent[0]).toBe('\x1b[Z')

// After ctrl is consumed, next press should be normal
act(() => {
numpad.props.onSendKey('a')
modeButton.props.onClick()
})

expect(sent[1]).toBe('a')
expect(sent[1]).toBe('\x1b[Z')
})

test('session switcher selects sessions when multiple are present', () => {
Expand Down
146 changes: 102 additions & 44 deletions src/client/components/DPad.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
/**
* DPad - Virtual joystick for mobile terminal navigation
* Long press to activate, drag in any direction to send arrow keys
* Uses joystick pattern so finger position doesn't obscure controls
* DPad - Virtual joystick for mobile terminal navigation and scrolling
* Tap to toggle between cursor mode (arrow keys) and scroll mode (tmux scroll)
* Long press to activate joystick, drag in any direction to send keys/scroll
*/

import { useState, useRef, useCallback, useEffect, type TouchEvent } from 'react'
import { MoveIcon } from '@untitledui-icons/react/line'

export type DPadMode = 'cursor' | 'scroll'

interface DPadProps {
onSendKey: (key: string) => void
onSendScroll?: (direction: 'up' | 'down') => void
disabled?: boolean
onRefocus?: () => void
isKeyboardVisible?: () => boolean
Expand All @@ -30,6 +33,7 @@ const REPEAT_INTERVAL_MIN = 400 // ms between keys at max distance (fast)
const REPEAT_INTERVAL_MAX = 1500 // ms between keys at min distance (slow)
const DEAD_ZONE = 15 // pixels from center before direction registers
const JOYSTICK_RADIUS = 70 // visual radius of joystick
const TAP_MAX_DURATION = 200 // ms - taps shorter than this toggle mode

function triggerHaptic(intensity: number = 10) {
if ('vibrate' in navigator) {
Expand Down Expand Up @@ -65,23 +69,42 @@ export function getRepeatInterval(distance: number): number {
return REPEAT_INTERVAL_MAX - normalizedDistance * (REPEAT_INTERVAL_MAX - REPEAT_INTERVAL_MIN)
}

// Scroll icon - vertical double arrows
const ScrollIcon = (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="7 4 12 0 17 4" />
<line x1="12" y1="0" x2="12" y2="24" />
<polyline points="7 20 12 24 17 20" />
</svg>
)

export default function DPad({
onSendKey,
onSendScroll,
disabled = false,
onRefocus,
isKeyboardVisible,
}: DPadProps) {
const [isOpen, setIsOpen] = useState(false)
const [mode, setMode] = useState<DPadMode>('cursor')
const [activeDirection, setActiveDirection] = useState<Direction>(null)
const [joystickCenter, setJoystickCenter] = useState({ x: 0, y: 0 })
const [knobOffset, setKnobOffset] = useState({ x: 0, y: 0 })

const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const longPressFiredRef = useRef(false)
const touchStartTimeRef = useRef(0)
const repeatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const repeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const wasKeyboardVisibleRef = useRef(false)
const currentDirectionRef = useRef<Direction>(null)
const currentDistanceRef = useRef(0)
const modeRef = useRef<DPadMode>('cursor')

// Keep modeRef in sync
useEffect(() => {
modeRef.current = mode
}, [mode])

// Clean up all timers
const clearAllTimers = useCallback(() => {
Expand Down Expand Up @@ -111,35 +134,50 @@ export default function DPad({
}
}, [])

// Schedule next repeat based on current distance
const scheduleNextRepeat = useCallback((key: string) => {
// Schedule next repeat based on current distance and mode
const scheduleNextRepeat = useCallback((direction: 'up' | 'down' | 'left' | 'right') => {
const interval = getRepeatInterval(currentDistanceRef.current)
repeatIntervalRef.current = setTimeout(() => {
if (currentDirectionRef.current) {
triggerHaptic(5)
onSendKey(key)
scheduleNextRepeat(key)
if (modeRef.current === 'scroll') {
if (direction === 'up' || direction === 'down') {
onSendScroll?.(direction)
}
} else {
onSendKey(ARROW_KEYS[direction])
}
scheduleNextRepeat(direction)
}
}, interval) as unknown as ReturnType<typeof setInterval>
}, [onSendKey])
}, [onSendKey, onSendScroll])

// Start sending a direction with auto-repeat
const startDirection = useCallback((direction: Direction, distance: number) => {
if (!direction || disabled) return

const key = ARROW_KEYS[direction]
// In scroll mode, ignore left/right
if (modeRef.current === 'scroll' && (direction === 'left' || direction === 'right')) {
return
}

currentDistanceRef.current = distance
triggerHaptic(8)
onSendKey(key)

if (modeRef.current === 'scroll') {
onSendScroll?.(direction as 'up' | 'down')
} else {
onSendKey(ARROW_KEYS[direction])
}

// Clear any existing repeat timers
stopKeyRepeat()

// Start auto-repeat after initial delay
repeatTimerRef.current = setTimeout(() => {
scheduleNextRepeat(key)
scheduleNextRepeat(direction)
}, REPEAT_INITIAL_DELAY)
}, [disabled, onSendKey, stopKeyRepeat, scheduleNextRepeat])
}, [disabled, onSendKey, onSendScroll, stopKeyRepeat, scheduleNextRepeat])

// Update direction based on finger position
const updateDirection = useCallback((clientX: number, clientY: number) => {
Expand Down Expand Up @@ -189,8 +227,11 @@ export default function DPad({

const touch = e.touches[0]
wasKeyboardVisibleRef.current = isKeyboardVisible?.() ?? false
longPressFiredRef.current = false
touchStartTimeRef.current = performance.now()

longPressTimerRef.current = setTimeout(() => {
longPressFiredRef.current = true
triggerHaptic(15)
// Position joystick centered above the touch point
setJoystickCenter({ x: touch.clientX, y: touch.clientY - 80 })
Expand Down Expand Up @@ -222,6 +263,13 @@ export default function DPad({

if (isOpen) {
closeJoystick()
} else if (!longPressFiredRef.current) {
// Short tap — toggle mode
const tapDuration = performance.now() - touchStartTimeRef.current
if (tapDuration < TAP_MAX_DURATION) {
triggerHaptic(10)
setMode(prev => prev === 'cursor' ? 'scroll' : 'cursor')
}
}
}, [isOpen, closeJoystick])

Expand All @@ -247,24 +295,28 @@ export default function DPad({
{ dir: 'left' as const, angle: 180, label: '←' },
]

const isScrollMode = mode === 'scroll'

return (
<>
{/* Trigger button */}
<button
type="button"
aria-label="Arrow keys"
aria-label={isScrollMode ? 'Scroll mode (tap to switch to cursor)' : 'Cursor mode (tap to switch to scroll)'}
className={`
terminal-key
flex items-center justify-center
h-11 min-w-[2.75rem] px-2.5
text-sm font-medium
bg-surface border border-border rounded-md
active:bg-hover active:scale-95
transition-transform duration-75
rounded-md
active:scale-95
transition-all duration-75
select-none
text-secondary
${disabled ? 'opacity-50' : ''}
${isOpen ? 'bg-hover scale-95' : ''}
${isOpen ? 'scale-95' : ''}
${isScrollMode
? 'bg-accent/20 text-accent border border-accent/40'
: 'bg-surface border border-border text-secondary active:bg-hover'}
`}
style={{ touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
onTouchStart={handleTriggerTouchStart}
Expand All @@ -273,7 +325,7 @@ export default function DPad({
onTouchCancel={handleTouchCancel}
disabled={disabled}
>
<MoveIcon width={20} height={20} />
{isScrollMode ? ScrollIcon : <MoveIcon width={20} height={20} />}
</button>

{/* Joystick overlay - renders in portal position */}
Expand Down Expand Up @@ -306,30 +358,36 @@ export default function DPad({
}}
>
{/* Direction indicators */}
{directionArrows.map(({ dir, angle, label }) => (
<div
key={dir}
className={`
absolute text-2xl font-bold
transition-all duration-75
${activeDirection === dir
? 'text-accent scale-125'
: 'text-white/60'}
`}
style={{
left: '50%',
top: '50%',
transform: `
translate(-50%, -50%)
rotate(${angle}deg)
translateX(${JOYSTICK_RADIUS - 25}px)
rotate(${-angle}deg)
`,
}}
>
{label}
</div>
))}
{directionArrows.map(({ dir, angle, label }) => {
const isHorizontal = dir === 'left' || dir === 'right'
const dimmed = isScrollMode && isHorizontal
return (
<div
key={dir}
className={`
absolute text-2xl font-bold
transition-all duration-75
${dimmed
? 'text-white/15'
: activeDirection === dir
? 'text-accent scale-125'
: 'text-white/60'}
`}
style={{
left: '50%',
top: '50%',
transform: `
translate(-50%, -50%)
rotate(${angle}deg)
translateX(${JOYSTICK_RADIUS - 25}px)
rotate(${-angle}deg)
`,
}}
>
{label}
</div>
)
})}

{/* Center knob */}
<div
Expand All @@ -353,7 +411,7 @@ export default function DPad({
{/* Direction label */}
{activeDirection && (
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 text-white text-sm font-medium bg-black/50 px-3 py-1 rounded-full">
{activeDirection.toUpperCase()}
{isScrollMode ? `SCROLL ${activeDirection.toUpperCase()}` : activeDirection.toUpperCase()}
</div>
)}
</div>
Expand Down
18 changes: 18 additions & 0 deletions src/client/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,23 @@ export default function Terminal({
[session, isReadOnly, sendMessage]
)

const handleSendScroll = useCallback(
(direction: 'up' | 'down') => {
if (!session || isReadOnly) return
const terminal = terminalRef.current
const cols = terminal?.cols ?? 80
const rows = terminal?.rows ?? 24
const col = Math.floor(cols / 2)
const row = Math.floor(rows / 2)
const button = direction === 'up' ? 64 : 65
sendMessage({ type: 'terminal-input', sessionId: session.id, data: `\x1b[<${button};${col};${row}M` })
if (direction === 'up') {
setTmuxCopyMode(true)
}
},
[session, isReadOnly, sendMessage]
)

const handleRefocus = useCallback(() => {
const container = containerRef.current
if (!container) return
Expand Down Expand Up @@ -1090,6 +1107,7 @@ export default function Terminal({
{session && (
<TerminalControls
onSendKey={handleSendKey}
onSendScroll={handleSendScroll}
disabled={connectionStatus !== 'connected' || isReadOnly}
sessions={sessions.map(s => ({ id: s.id, name: s.name, status: s.status }))}
currentSessionId={session.id}
Expand Down
Loading