diff --git a/web/src/routes/sessions/terminal.tsx b/web/src/routes/sessions/terminal.tsx
index acfe4273..5ccc9fe4 100644
--- a/web/src/routes/sessions/terminal.tsx
+++ b/web/src/routes/sessions/terminal.tsx
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import type { PointerEvent } from 'react'
import { useParams } from '@tanstack/react-router'
import type { Terminal } from '@xterm/xterm'
import { useAppContext } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { useSession } from '@/hooks/queries/useSession'
import { useTerminalSocket } from '@/hooks/useTerminalSocket'
+import { useLongPress } from '@/hooks/useLongPress'
import { TerminalView } from '@/components/Terminal/TerminalView'
import { LoadingState } from '@/components/LoadingState'
function BackIcon() {
@@ -32,8 +34,8 @@ function ConnectionIndicator(props: { status: 'idle' | 'connecting' | 'connected
const colorClass = isConnected
? 'bg-emerald-500'
: isConnecting
- ? 'bg-amber-400 animate-pulse'
- : 'bg-[var(--app-hint)]'
+ ? 'bg-amber-400 animate-pulse'
+ : 'bg-[var(--app-hint)]'
return (
@@ -42,13 +44,131 @@ function ConnectionIndicator(props: { status: 'idle' | 'connecting' | 'connected
)
}
-const QUICK_INPUTS = [
- { label: 'Esc', sequence: '\u001b', description: 'Escape' },
- { label: 'Tab', sequence: '\t', description: 'Tab' },
- { label: 'Home', sequence: '\u001b[H', description: 'Home' },
- { label: 'End', sequence: '\u001b[F', description: 'End' }
+type QuickInput = {
+ label: string
+ sequence?: string
+ description: string
+ modifier?: 'ctrl' | 'alt'
+ popup?: {
+ label: string
+ sequence: string
+ description: string
+ }
+}
+
+type ModifierState = {
+ ctrl: boolean
+ alt: boolean
+}
+
+function applyModifierState(sequence: string, state: ModifierState): string {
+ let modified = sequence
+ if (state.alt) {
+ modified = `\u001b${modified}`
+ }
+ if (state.ctrl && modified.length === 1) {
+ const code = modified.toUpperCase().charCodeAt(0)
+ if (code >= 64 && code <= 95) {
+ modified = String.fromCharCode(code - 64)
+ }
+ }
+ return modified
+}
+
+function shouldResetModifiers(sequence: string, state: ModifierState): boolean {
+ if (!sequence) {
+ return false
+ }
+ return state.ctrl || state.alt
+}
+
+const QUICK_INPUT_ROWS: QuickInput[][] = [
+ [
+ { label: 'Esc', sequence: '\u001b', description: 'Escape' },
+ {
+ label: '/',
+ sequence: '/',
+ description: 'Forward slash',
+ popup: { label: '?', sequence: '?', description: 'Question mark' },
+ },
+ {
+ label: '-',
+ sequence: '-',
+ description: 'Hyphen',
+ popup: { label: '|', sequence: '|', description: 'Pipe' },
+ },
+ { label: 'Home', sequence: '\u001b[H', description: 'Home' },
+ { label: '↑', sequence: '\u001b[A', description: 'Arrow up' },
+ { label: 'End', sequence: '\u001b[F', description: 'End' },
+ { label: 'PgUp', sequence: '\u001b[5~', description: 'Page up' },
+ ],
+ [
+ { label: 'Tab', sequence: '\t', description: 'Tab' },
+ { label: 'Ctrl', description: 'Control', modifier: 'ctrl' },
+ { label: 'Alt', description: 'Alternate', modifier: 'alt' },
+ { label: '←', sequence: '\u001b[D', description: 'Arrow left' },
+ { label: '↓', sequence: '\u001b[B', description: 'Arrow down' },
+ { label: '→', sequence: '\u001b[C', description: 'Arrow right' },
+ { label: 'PgDn', sequence: '\u001b[6~', description: 'Page down' },
+ ],
]
+function QuickKeyButton(props: {
+ input: QuickInput
+ disabled: boolean
+ isActive: boolean
+ onPress: (sequence: string) => void
+ onToggleModifier: (modifier: 'ctrl' | 'alt') => void
+}) {
+ const { input, disabled, isActive, onPress, onToggleModifier } = props
+ const modifier = input.modifier
+ const popupSequence = input.popup?.sequence
+ const popupDescription = input.popup?.description
+ const hasPopup = Boolean(popupSequence)
+ const longPressDisabled = disabled || Boolean(modifier) || !hasPopup
+
+ const handleClick = useCallback(() => {
+ if (modifier) {
+ onToggleModifier(modifier)
+ return
+ }
+ onPress(input.sequence ?? '')
+ }, [modifier, onToggleModifier, onPress, input.sequence])
+
+ const handlePointerDown = useCallback((event: PointerEvent
) => {
+ if (event.pointerType === 'touch') {
+ event.preventDefault()
+ }
+ }, [])
+
+ const longPressHandlers = useLongPress({
+ onLongPress: () => {
+ if (popupSequence && !modifier) {
+ onPress(popupSequence)
+ }
+ },
+ onClick: handleClick,
+ disabled: longPressDisabled,
+ })
+
+ return (
+
+ )
+}
+
export default function TerminalPage() {
const { sessionId } = useParams({ from: '/sessions/$sessionId/terminal' })
const { api, token, baseUrl } = useAppContext()
@@ -64,7 +184,10 @@ export default function TerminalPage() {
const inputDisposableRef = useRef<{ dispose: () => void } | null>(null)
const connectOnceRef = useRef(false)
const lastSizeRef = useRef<{ cols: number; rows: number } | null>(null)
+ const modifierStateRef = useRef({ ctrl: false, alt: false })
const [exitInfo, setExitInfo] = useState<{ code: number | null; signal: string | null } | null>(null)
+ const [ctrlActive, setCtrlActive] = useState(false)
+ const [altActive, setAltActive] = useState(false)
const {
state: terminalState,
@@ -73,7 +196,7 @@ export default function TerminalPage() {
resize,
disconnect,
onOutput,
- onExit
+ onExit,
} = useTerminalSocket({
token,
sessionId,
@@ -95,26 +218,52 @@ export default function TerminalPage() {
})
}, [onExit])
- const handleTerminalMount = useCallback((terminal: Terminal) => {
- terminalRef.current = terminal
- inputDisposableRef.current?.dispose()
- inputDisposableRef.current = terminal.onData((data) => {
- write(data)
- })
- }, [write])
+ useEffect(() => {
+ modifierStateRef.current = { ctrl: ctrlActive, alt: altActive }
+ }, [ctrlActive, altActive])
- const handleResize = useCallback((cols: number, rows: number) => {
- lastSizeRef.current = { cols, rows }
- if (!session?.active) {
- return
- }
- if (!connectOnceRef.current) {
- connectOnceRef.current = true
- connect(cols, rows)
- } else {
- resize(cols, rows)
- }
- }, [session?.active, connect, resize])
+ const resetModifiers = useCallback(() => {
+ setCtrlActive(false)
+ setAltActive(false)
+ }, [])
+
+ const dispatchSequence = useCallback(
+ (sequence: string, modifierState: ModifierState) => {
+ write(applyModifierState(sequence, modifierState))
+ if (shouldResetModifiers(sequence, modifierState)) {
+ resetModifiers()
+ }
+ },
+ [write, resetModifiers]
+ )
+
+ const handleTerminalMount = useCallback(
+ (terminal: Terminal) => {
+ terminalRef.current = terminal
+ inputDisposableRef.current?.dispose()
+ inputDisposableRef.current = terminal.onData((data) => {
+ const modifierState = modifierStateRef.current
+ dispatchSequence(data, modifierState)
+ })
+ },
+ [dispatchSequence]
+ )
+
+ const handleResize = useCallback(
+ (cols: number, rows: number) => {
+ lastSizeRef.current = { cols, rows }
+ if (!session?.active) {
+ return
+ }
+ if (!connectOnceRef.current) {
+ connectOnceRef.current = true
+ connect(cols, rows)
+ } else {
+ resize(cols, rows)
+ }
+ },
+ [session?.active, connect, resize]
+ )
useEffect(() => {
if (!session?.active) {
@@ -163,13 +312,34 @@ export default function TerminalPage() {
}, [terminalState.status])
const quickInputDisabled = !session?.active || terminalState.status !== 'connected'
- const handleQuickInput = useCallback((sequence: string) => {
- if (quickInputDisabled) {
- return
- }
- write(sequence)
- terminalRef.current?.focus()
- }, [quickInputDisabled, write])
+ const handleQuickInput = useCallback(
+ (sequence: string) => {
+ if (quickInputDisabled) {
+ return
+ }
+ const modifierState = { ctrl: ctrlActive, alt: altActive }
+ dispatchSequence(sequence, modifierState)
+ terminalRef.current?.focus()
+ },
+ [quickInputDisabled, ctrlActive, altActive, dispatchSequence]
+ )
+
+ const handleModifierToggle = useCallback(
+ (modifier: 'ctrl' | 'alt') => {
+ if (quickInputDisabled) {
+ return
+ }
+ if (modifier === 'ctrl') {
+ setCtrlActive((value) => !value)
+ setAltActive(false)
+ } else {
+ setAltActive((value) => !value)
+ setCtrlActive(false)
+ }
+ terminalRef.current?.focus()
+ },
+ [quickInputDisabled]
+ )
if (!session) {
return (
@@ -221,36 +391,43 @@ export default function TerminalPage() {
{exitInfo ? (
- Terminal exited{exitInfo.code !== null ? ` with code ${exitInfo.code}` : ''}{exitInfo.signal ? ` (${exitInfo.signal})` : ''}.
+ Terminal exited{exitInfo.code !== null ? ` with code ${exitInfo.code}` : ''}
+ {exitInfo.signal ? ` (${exitInfo.signal})` : ''}.
) : null}
-
- {QUICK_INPUTS.map((input) => (
-