@@ -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,60 @@ 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+ // Modifiers only apply to a single keystroke. Multi-char input (paste,
461+ // IME composition output, multi-byte arrow escape sequences) passes
462+ // through WITHOUT consuming an armed modifier — the next real single
463+ // keypress picks it up. Same for Ctrl on a key outside the encodable
464+ // ASCII range (digits, Enter, Backspace, etc.): armed Ctrl is preserved
465+ // so the user isn't penalized for a misfire.
466+ if ( raw . length !== 1 ) return raw
467+ let ch = raw
468+ let ctrlApplied = false
469+ if ( m . ctrl !== 'off' ) {
470+ const code = ch . toUpperCase ( ) . charCodeAt ( 0 )
471+ if ( code >= 64 && code <= 95 ) { // @, A-Z, [, \, ], ^, _
472+ ch = String . fromCharCode ( code & 0x1f )
473+ ctrlApplied = true
474+ }
475+ }
476+ const altApplied = m . alt !== 'off' // Alt prefixes ESC for any single char (incl. Enter → ESC+CR)
477+ const out = ( altApplied ? '\x1b' : '' ) + ch
478+ // Consume armed modifiers only when actually applied. Locked persist.
479+ const consumeCtrl = m . ctrl === 'armed' && ctrlApplied
480+ const consumeAlt = m . alt === 'armed' && altApplied
481+ if ( consumeCtrl || consumeAlt ) {
482+ const next = {
483+ ctrl : consumeCtrl ? 'off' as ModState : m . ctrl ,
484+ alt : consumeAlt ? 'off' as ModState : m . alt ,
485+ }
486+ modsRef . current = next
487+ setCtrlMod ( next . ctrl ) ; setAltMod ( next . alt )
426488 }
489+ return out
427490 } , [ ] )
428491
492+ // Single chokepoint for ALL terminal input. Applies sticky modifiers, then
493+ // sends a sequenced frame and keeps it in a pending queue until the server
494+ // acks it (frames are replayed on reconnect via the hello handshake).
495+ const sendToWs = useCallback ( ( raw : string ) => {
496+ const data = applyMods ( raw )
497+ if ( ! data ) return
498+ const seq = ++ inputSeqRef . current
499+ const frame = JSON . stringify ( { type : 'i' , epoch : inputEpochRef . current , seq, data } )
500+ const pend = pendingInputsRef . current
501+ pend . set ( seq , frame )
502+ if ( pend . size > MAX_PENDING_INPUT_FRAMES ) { const k = pend . keys ( ) . next ( ) . value as number ; pend . delete ( k ) }
503+ const ws = wsRef . current
504+ if ( ws && ws . readyState === WebSocket . OPEN ) ws . send ( frame )
505+ } , [ applyMods ] )
506+
429507 useEffect ( ( ) => {
430508 applyTheme ( themeMode )
431509 } , [ themeMode , applyTheme ] )
@@ -1090,8 +1168,8 @@ export default function Terminal({ token }: Props) {
10901168 seq = '\x1b[3~'
10911169 }
10921170
1093- if ( seq && wsRef . current ?. readyState === WebSocket . OPEN ) {
1094- wsRef . current . send ( seq )
1171+ if ( seq ) {
1172+ sendToWs ( seq )
10951173 }
10961174 }
10971175
@@ -1118,7 +1196,7 @@ export default function Terminal({ token }: Props) {
11181196 } )
11191197
11201198 // 键盘输入 → 发送到当前 WebSocket
1121- term . onData ( ( data ) => wsRef . current ?. send ( data ) )
1199+ term . onData ( ( data ) => sendToWs ( data ) )
11221200
11231201 // 滚动位置追踪 → 浮动回底部按钮
11241202 term . onScroll ( ( ) => {
@@ -1366,6 +1444,20 @@ export default function Terminal({ token }: Props) {
13661444 userScrolledRef . current = hasSavedScroll
13671445 hasConnectedRef . current = false
13681446
1447+ // New window session → fresh input epoch/seq and empty pending queue.
1448+ // (Reconnects WITHIN this effect run keep these, so unacked frames replay.)
1449+ // Prefer crypto.randomUUID() for a collision-safe epoch (matters if multiple
1450+ // clients (re)connect to the same PTY at the same wall-clock instant), but
1451+ // fall back when it is unavailable: it's only defined in secure contexts
1452+ // (HTTPS / localhost), and self-hosted deployments often run over plain HTTP
1453+ // on a LAN/mesh IP where calling it would throw and break the WS setup.
1454+ inputEpochRef . current = ( typeof crypto !== 'undefined' && typeof crypto . randomUUID === 'function' )
1455+ ? crypto . randomUUID ( )
1456+ : Math . random ( ) . toString ( 36 ) . slice ( 2 ) + Date . now ( ) . toString ( 36 )
1457+ inputSeqRef . current = 0
1458+ pendingInputsRef . current = new Map ( )
1459+ serverSidRef . current = null
1460+
13691461 const protocol = location . protocol === 'https:' ? 'wss:' : 'ws:'
13701462
13711463 // 延迟显示 loading,避免快速连接时的闪烁
@@ -1389,6 +1481,7 @@ export default function Terminal({ token }: Props) {
13891481 const s = activeTmuxSessionRef . current
13901482 const wi = activeWindowIndexRef . current
13911483 const newWs = new WebSocket ( `${ protocol } //${ location . host } /ws?token=${ encodeURIComponent ( token ) } &window=${ wi } &session=${ encodeURIComponent ( s ) } ` )
1484+ newWs . binaryType = 'arraybuffer' // control frames (hello/ack) arrive as binary
13921485 wsRef . current = newWs
13931486
13941487 newWs . onopen = ( ) => {
@@ -1411,6 +1504,11 @@ export default function Terminal({ token }: Props) {
14111504 // a same-size resize is a no-op in tmux and no repaint is sent.
14121505 // The rows-1 nudge guarantees a size change → SIGWINCH → tmux pushes a
14131506 // full repaint. The rAF below immediately corrects to the real dimensions.
1507+ //
1508+ // Resize is a control message (idempotent, last-write-wins) so it
1509+ // intentionally bypasses the reliable input pipeline (sendToWs):
1510+ // only typed input goes through seq/ack/dedup; resize / heartbeat
1511+ // are fire-and-forget and re-sent on reconnect by this very block.
14141512 newWs . send ( JSON . stringify ( { type : 'resize' , cols : term . cols , rows : Math . max ( term . rows - 1 , 5 ) } ) )
14151513 }
14161514 // Follow-up fit: re-measures layout and sends correct final dimensions,
@@ -1424,6 +1522,29 @@ export default function Terminal({ token }: Props) {
14241522 }
14251523
14261524 newWs . onmessage = ( e ) => {
1525+ // Binary frames are control messages (hello / ack), never terminal output.
1526+ if ( typeof e . data !== 'string' ) {
1527+ try {
1528+ const msg = JSON . parse ( new TextDecoder ( ) . decode ( e . data as ArrayBuffer ) )
1529+ if ( msg . t === 'ack' ) {
1530+ pendingInputsRef . current . delete ( msg . seq )
1531+ } else if ( msg . t === 'hello' ) {
1532+ const prev = serverSidRef . current
1533+ if ( prev && prev !== msg . sid ) {
1534+ // Server PTY entry was recreated (idle cleanup/crash) → unacked
1535+ // frames belong to a dead instance; drop them to avoid misapply.
1536+ pendingInputsRef . current = new Map ( )
1537+ } else if ( newWs . readyState === WebSocket . OPEN ) {
1538+ // Same entry (reconnect) or first connect → replay unacked frames
1539+ // in seq order. Server dedups, so this can't duplicate input.
1540+ const entries = [ ...pendingInputsRef . current . entries ( ) ] . sort ( ( a , b ) => a [ 0 ] - b [ 0 ] )
1541+ for ( const [ , f ] of entries ) newWs . send ( f )
1542+ }
1543+ serverSidRef . current = msg . sid
1544+ }
1545+ } catch { /* ignore malformed control frame */ }
1546+ return
1547+ }
14271548 writeTerm ( e . data )
14281549 if ( ! userScrolledRef . current ) termRef . current ?. scrollToBottom ( )
14291550 }
@@ -1635,6 +1756,9 @@ export default function Terminal({ token }: Props) {
16351756 onShowCopySheet : ( text : string ) => setCopySheetText ( text ) ,
16361757 collapsed : toolbarCollapsed ,
16371758 onCollapsedChange : setToolbarCollapsed ,
1759+ ctrlMod,
1760+ altMod,
1761+ onCycleMod : cycleMod ,
16381762 }
16391763
16401764 return (
0 commit comments