- }, [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) => {
@@ -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 })
@@ -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])
@@ -247,24 +295,28 @@ export default function DPad({
{ dir: 'left' as const, angle: 180, label: '←' },
]
+ const isScrollMode = mode === 'scroll'
+
return (
<>
{/* Trigger button */}
{/* Joystick overlay - renders in portal position */}
@@ -306,30 +358,36 @@ export default function DPad({
}}
>
{/* Direction indicators */}
- {directionArrows.map(({ dir, angle, label }) => (
-
- {label}
-
- ))}
+ {directionArrows.map(({ dir, angle, label }) => {
+ const isHorizontal = dir === 'left' || dir === 'right'
+ const dimmed = isScrollMode && isHorizontal
+ return (
+
+ {label}
+
+ )
+ })}
{/* Center knob */}
- {activeDirection.toUpperCase()}
+ {isScrollMode ? `SCROLL ${activeDirection.toUpperCase()}` : activeDirection.toUpperCase()}
)}
diff --git a/src/client/components/Terminal.tsx b/src/client/components/Terminal.tsx
index 9d6de984..910b33c4 100644
--- a/src/client/components/Terminal.tsx
+++ b/src/client/components/Terminal.tsx
@@ -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
@@ -1090,6 +1107,7 @@ export default function Terminal({
{session && (
({ id: s.id, name: s.name, status: s.status }))}
currentSessionId={session.id}
diff --git a/src/client/components/TerminalControls.tsx b/src/client/components/TerminalControls.tsx
index bac27092..571aac67 100644
--- a/src/client/components/TerminalControls.tsx
+++ b/src/client/components/TerminalControls.tsx
@@ -9,7 +9,6 @@ import type { TouchEvent as ReactTouchEvent } from 'react'
import type { Session } from '@shared/types'
import { CornerDownLeftIcon } from '@untitledui-icons/react/line'
import DPad from './DPad'
-import NumPad from './NumPad'
import { isIOSDevice } from '../utils/device'
interface SessionInfo {
@@ -20,6 +19,7 @@ interface SessionInfo {
interface TerminalControlsProps {
onSendKey: (key: string) => void
+ onSendScroll?: (direction: 'up' | 'down') => void
disabled?: boolean
sessions: SessionInfo[]
currentSessionId: string | null
@@ -113,8 +113,17 @@ const statusDot: Record = {
unknown: 'bg-muted',
}
+// Mode toggle icon (Shift+Tab symbol)
+const ModeToggleIcon = (
+
+)
+
export default function TerminalControls({
onSendKey,
+ onSendScroll,
disabled = false,
sessions,
currentSessionId,
@@ -469,17 +478,34 @@ export default function TerminalControls({
))}
- {/* NumPad for number input */}
- e.preventDefault()}
+ onTouchStart={handleTouchAction(() => handlePress('\x1b[Z'))}
+ onClick={handleClickAction(() => handlePress('\x1b[Z'))}
disabled={disabled}
- onRefocus={onRefocus}
- isKeyboardVisible={isKeyboardVisible}
- />
+ >
+ {ModeToggleIcon}
+
- {/* D-pad for arrow keys */}
+ {/* D-pad for arrow keys / scroll */}
{
)
expect(setOptionCall).toBeTruthy()
expect(setOptionCall).toContain('off')
- expect(setOptionCall).toContain('-t')
- expect(setOptionCall).toContain(sessionName)
+ expect(setOptionCall).toContain('-g')
})
test('setMouseMode applies change immediately', () => {
diff --git a/start-agentboard.sh b/start-agentboard.sh
new file mode 100755
index 00000000..e8b22ec1
--- /dev/null
+++ b/start-agentboard.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+# Start agentboard inside a tmux session for proper TTY support.
+# Used by the launchd agent for auto-start on login.
+
+export PATH="/Users/kenneth/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
+export TLS_CERT="/Users/kenneth/.agentboard/tls-cert.pem"
+export TLS_KEY="/Users/kenneth/.agentboard/tls-key.pem"
+export DISCOVER_PREFIXES="infra"
+
+# Prevent Claude Code nesting detection in tmux children
+unset CLAUDECODE CLAUDE_CODE_ENTRYPOINT CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS
+
+WORKING_DIR="/Users/kenneth/Desktop/lab/infra/agentboard"
+
+# Ensure the managed "agentboard" tmux session exists
+if ! tmux has-session -t agentboard 2>/dev/null; then
+ tmux new-session -d -s agentboard
+fi
+
+# Ensure the "infra" session exists with the agentboard-server window
+if tmux has-session -t infra 2>/dev/null; then
+ if ! tmux list-windows -t infra -F '#{window_name}' | grep -q "^agentboard-server$"; then
+ tmux new-window -t infra -n agentboard-server -c "$WORKING_DIR"
+ tmux send-keys -t infra:agentboard-server "TLS_CERT=$TLS_CERT TLS_KEY=$TLS_KEY DISCOVER_PREFIXES=$DISCOVER_PREFIXES bun run start" Enter
+ fi
+else
+ tmux new-session -d -s infra -n agentboard-server -c "$WORKING_DIR"
+ tmux send-keys -t infra:agentboard-server "TLS_CERT=$TLS_CERT TLS_KEY=$TLS_KEY DISCOVER_PREFIXES=$DISCOVER_PREFIXES bun run start" Enter
+fi