@@ -117,6 +117,11 @@ const THEME_KEY = 'nexus_theme'
117117const WINDOW_KEY = 'nexus_window'
118118const TAP_THRESHOLD = 8
119119const MAX_UPLOAD_NOTIFICATIONS = 5
120+ // Cap on unacked input frames the client keeps queued for replay. Each frame
121+ // is one keystroke; 1000 means the user typed 1000 chars while the server
122+ // hadn't acked any (long disconnect). Beyond this the oldest is dropped to
123+ // bound memory; in practice acks return in tens of milliseconds.
124+ const MAX_PENDING_INPUT_FRAMES = 1000
120125
121126export type ThemeMode = 'dark' | 'light'
122127
@@ -210,6 +215,31 @@ export default function Terminal({ token }: Props) {
210215 const userScrolledRef = useRef ( false )
211216 const lastContainerSizeRef = useRef ( { w : 0 , h : 0 } )
212217 const inputRef = useRef < HTMLInputElement > ( null )
218+
219+ // ---- Reliable input transport (no-duplicate / no-loss on flaky network) ----
220+ // Each input frame carries (epoch, seq). The server dedups by seq, so resends
221+ // on reconnect can't double-apply. epoch/seq/pending reset per window session
222+ // (Effect B); reconnects within a session reuse them to replay unacked frames.
223+ const inputEpochRef = useRef ( '' )
224+ const inputSeqRef = useRef ( 0 )
225+ const pendingInputsRef = useRef < Map < number , string > > ( new Map ( ) )
226+ const serverSidRef = useRef < string | null > ( null )
227+
228+ // ---- Sticky modifier keys (Termux-style): off -> armed -> locked -> off ----
229+ // armed applies to the next typed key then clears; locked persists until tapped off.
230+ type ModState = 'off' | 'armed' | 'locked'
231+ const [ ctrlMod , setCtrlMod ] = useState < ModState > ( 'off' )
232+ const [ altMod , setAltMod ] = useState < ModState > ( 'off' )
233+ const modsRef = useRef < { ctrl : ModState ; alt : ModState } > ( { ctrl : 'off' , alt : 'off' } )
234+ useEffect ( ( ) => { modsRef . current = { ctrl : ctrlMod , alt : altMod } } , [ ctrlMod , altMod ] )
235+
236+ const cycleMod = useCallback ( ( name : 'ctrl' | 'alt' ) => {
237+ const cur = modsRef . current [ name ]
238+ const next : ModState = cur === 'off' ? 'armed' : cur === 'armed' ? 'locked' : 'off'
239+ modsRef . current = { ...modsRef . current , [ name ] : next }
240+ if ( name === 'ctrl' ) setCtrlMod ( next ) ; else setAltMod ( next )
241+ } , [ ] )
242+
213243 const [ windows , setWindows ] = useState < TmuxWindow [ ] > ( [ ] )
214244 const [ activeWindowIndex , setActiveWindowIndex ] = useState ( ( ) => parseInt ( localStorage . getItem ( WINDOW_KEY ) || '0' , 10 ) )
215245 const [ showSettings , setShowSettings ] = useState ( false )
@@ -420,12 +450,48 @@ export default function Terminal({ token }: Props) {
420450 setTimeout ( ( ) => setCopiedId ( null ) , 2000 )
421451 } , [ copyToClipboard ] )
422452
423- const sendToWs = useCallback ( ( data : string ) => {
424- if ( wsRef . current ?. readyState === WebSocket . OPEN ) {
425- wsRef . current . send ( data )
453+ // Apply armed/locked Ctrl/Alt to a raw keystroke (single printable char).
454+ // Ctrl: map to its control byte (A-Z, @[\]^_); Alt: prefix ESC. Consumes
455+ // armed modifiers (locked persist). Non-printable / multi-char data passes
456+ // through but still consumes armed modifiers (the "next key" was used).
457+ const applyMods = useCallback ( ( raw : string ) : string => {
458+ const m = modsRef . current
459+ if ( m . ctrl === 'off' && m . alt === 'off' ) return raw
460+ let out = raw
461+ // Apply to any single keystroke, incl. control keys: Alt+Enter → ESC+CR
462+ // (standard meta-Enter, e.g. newline in Claude Code), Alt+Backspace → ESC+DEL.
463+ // Multi-char input (paste / IME / arrow escape sequences) passes through.
464+ if ( raw . length === 1 ) {
465+ let ch = raw
466+ if ( m . ctrl !== 'off' ) {
467+ const code = ch . toUpperCase ( ) . charCodeAt ( 0 )
468+ if ( code >= 64 && code <= 95 ) ch = String . fromCharCode ( code & 0x1f ) // @,A-Z,[,\,],^,_
469+ }
470+ out = ( m . alt !== 'off' ? '\x1b' : '' ) + ch
471+ }
472+ if ( m . ctrl === 'armed' || m . alt === 'armed' ) {
473+ const next = { ctrl : m . ctrl === 'armed' ? 'off' as ModState : m . ctrl , alt : m . alt === 'armed' ? 'off' as ModState : m . alt }
474+ modsRef . current = next
475+ setCtrlMod ( next . ctrl ) ; setAltMod ( next . alt )
426476 }
477+ return out
427478 } , [ ] )
428479
480+ // Single chokepoint for ALL terminal input. Applies sticky modifiers, then
481+ // sends a sequenced frame and keeps it in a pending queue until the server
482+ // acks it (frames are replayed on reconnect via the hello handshake).
483+ const sendToWs = useCallback ( ( raw : string ) => {
484+ const data = applyMods ( raw )
485+ if ( ! data ) return
486+ const seq = ++ inputSeqRef . current
487+ const frame = JSON . stringify ( { type : 'i' , epoch : inputEpochRef . current , seq, data } )
488+ const pend = pendingInputsRef . current
489+ pend . set ( seq , frame )
490+ if ( pend . size > MAX_PENDING_INPUT_FRAMES ) { const k = pend . keys ( ) . next ( ) . value as number ; pend . delete ( k ) }
491+ const ws = wsRef . current
492+ if ( ws && ws . readyState === WebSocket . OPEN ) ws . send ( frame )
493+ } , [ applyMods ] )
494+
429495 useEffect ( ( ) => {
430496 applyTheme ( themeMode )
431497 } , [ themeMode , applyTheme ] )
@@ -1090,8 +1156,8 @@ export default function Terminal({ token }: Props) {
10901156 seq = '\x1b[3~'
10911157 }
10921158
1093- if ( seq && wsRef . current ?. readyState === WebSocket . OPEN ) {
1094- wsRef . current . send ( seq )
1159+ if ( seq ) {
1160+ sendToWs ( seq )
10951161 }
10961162 }
10971163
@@ -1118,7 +1184,7 @@ export default function Terminal({ token }: Props) {
11181184 } )
11191185
11201186 // 键盘输入 → 发送到当前 WebSocket
1121- term . onData ( ( data ) => wsRef . current ?. send ( data ) )
1187+ term . onData ( ( data ) => sendToWs ( data ) )
11221188
11231189 // 滚动位置追踪 → 浮动回底部按钮
11241190 term . onScroll ( ( ) => {
@@ -1366,6 +1432,13 @@ export default function Terminal({ token }: Props) {
13661432 userScrolledRef . current = hasSavedScroll
13671433 hasConnectedRef . current = false
13681434
1435+ // New window session → fresh input epoch/seq and empty pending queue.
1436+ // (Reconnects WITHIN this effect run keep these, so unacked frames replay.)
1437+ inputEpochRef . current = Math . random ( ) . toString ( 36 ) . slice ( 2 ) + Date . now ( ) . toString ( 36 )
1438+ inputSeqRef . current = 0
1439+ pendingInputsRef . current = new Map ( )
1440+ serverSidRef . current = null
1441+
13691442 const protocol = location . protocol === 'https:' ? 'wss:' : 'ws:'
13701443
13711444 // 延迟显示 loading,避免快速连接时的闪烁
@@ -1389,6 +1462,7 @@ export default function Terminal({ token }: Props) {
13891462 const s = activeTmuxSessionRef . current
13901463 const wi = activeWindowIndexRef . current
13911464 const newWs = new WebSocket ( `${ protocol } //${ location . host } /ws?token=${ encodeURIComponent ( token ) } &window=${ wi } &session=${ encodeURIComponent ( s ) } ` )
1465+ newWs . binaryType = 'arraybuffer' // control frames (hello/ack) arrive as binary
13921466 wsRef . current = newWs
13931467
13941468 newWs . onopen = ( ) => {
@@ -1424,6 +1498,29 @@ export default function Terminal({ token }: Props) {
14241498 }
14251499
14261500 newWs . onmessage = ( e ) => {
1501+ // Binary frames are control messages (hello / ack), never terminal output.
1502+ if ( typeof e . data !== 'string' ) {
1503+ try {
1504+ const msg = JSON . parse ( new TextDecoder ( ) . decode ( e . data as ArrayBuffer ) )
1505+ if ( msg . t === 'ack' ) {
1506+ pendingInputsRef . current . delete ( msg . seq )
1507+ } else if ( msg . t === 'hello' ) {
1508+ const prev = serverSidRef . current
1509+ if ( prev && prev !== msg . sid ) {
1510+ // Server PTY entry was recreated (idle cleanup/crash) → unacked
1511+ // frames belong to a dead instance; drop them to avoid misapply.
1512+ pendingInputsRef . current = new Map ( )
1513+ } else if ( newWs . readyState === WebSocket . OPEN ) {
1514+ // Same entry (reconnect) or first connect → replay unacked frames
1515+ // in seq order. Server dedups, so this can't duplicate input.
1516+ const entries = [ ...pendingInputsRef . current . entries ( ) ] . sort ( ( a , b ) => a [ 0 ] - b [ 0 ] )
1517+ for ( const [ , f ] of entries ) newWs . send ( f )
1518+ }
1519+ serverSidRef . current = msg . sid
1520+ }
1521+ } catch { /* ignore malformed control frame */ }
1522+ return
1523+ }
14271524 writeTerm ( e . data )
14281525 if ( ! userScrolledRef . current ) termRef . current ?. scrollToBottom ( )
14291526 }
@@ -1635,6 +1732,9 @@ export default function Terminal({ token }: Props) {
16351732 onShowCopySheet : ( text : string ) => setCopySheetText ( text ) ,
16361733 collapsed : toolbarCollapsed ,
16371734 onCollapsedChange : setToolbarCollapsed ,
1735+ ctrlMod,
1736+ altMod,
1737+ onCycleMod : cycleMod ,
16381738 }
16391739
16401740 return (
0 commit comments