Skip to content

Commit 4966c4e

Browse files
committed
feat(terminal): reliable input transport and sticky modifier keys
Frame terminal input as {type:'i', epoch, seq, data}. The client queues unacked frames and replays them on reconnect via a binary hello/ack handshake; the server dedups by (epoch, seq), so a flaky connection can no longer drop or duplicate keystrokes. Raw (non-framed) input stays backward-compatible. Add Termux-style sticky Ctrl/Alt toolbar keys (tap to arm, tap again to lock) so any key on the soft keyboard composes a Ctrl/Alt sequence (e.g. Alt+Enter -> ESC+CR for a newline). All input paths funnel through one send chokepoint so modifiers apply uniformly.
1 parent 3abdae2 commit 4966c4e

6 files changed

Lines changed: 219 additions & 31 deletions

File tree

frontend/src/Terminal.tsx

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ const THEME_KEY = 'nexus_theme'
117117
const WINDOW_KEY = 'nexus_window'
118118
const TAP_THRESHOLD = 8
119119
const 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

121126
export 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 (

frontend/src/Toolbar.tsx

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ interface Props {
3030
collapsed?: boolean
3131
/** Callback when collapsed state changes (for controlled mode) */
3232
onCollapsedChange?: (collapsed: boolean) => void
33+
/** Sticky modifier states (Termux-style) for highlighting the Ctrl/Alt keys. */
34+
ctrlMod?: 'off' | 'armed' | 'locked'
35+
altMod?: 'off' | 'armed' | 'locked'
36+
/** Toggle a sticky modifier (off → armed → locked → off). */
37+
onCycleMod?: (name: 'ctrl' | 'alt') => void
3338
}
3439

3540
// Convert a user-typed label (e.g. "^X", "M-b", "Esc") to the raw seq bytes.
@@ -71,14 +76,31 @@ const COLLAPSED_KEY = 'nexus_toolbar_collapsed'
7176
// PC 端断点
7277
const PC_BREAKPOINT = 768
7378

79+
// One-time migration: surface the new Ctrl/Alt sticky modifier keys for users
80+
// whose saved config predates them. Runs once (guarded by a flag), so a user
81+
// who later removes the keys keeps them removed.
82+
const MODS_MIGRATION_KEY = 'nexus_toolbar_mods_v1'
83+
function migrateMods(cfg: ToolbarConfig): ToolbarConfig {
84+
try {
85+
if (localStorage.getItem(MODS_MIGRATION_KEY)) return cfg
86+
localStorage.setItem(MODS_MIGRATION_KEY, '1')
87+
const have = new Set([...(cfg.pinned ?? []), ...(cfg.expanded ?? [])])
88+
const add = ['mod-ctrl', 'mod-alt'].filter(id => !have.has(id))
89+
if (add.length === 0) return cfg
90+
const next = { ...cfg, pinned: [...add, ...(cfg.pinned ?? [])] }
91+
localStorage.setItem(CONFIG_KEY, JSON.stringify(next))
92+
return next
93+
} catch { return cfg }
94+
}
95+
7496
function loadConfig(): ToolbarConfig {
7597
try {
7698
const s = localStorage.getItem(CONFIG_KEY)
77-
if (s) return JSON.parse(s)
99+
if (s) return migrateMods(JSON.parse(s))
78100
} catch {}
79101
try {
80102
const d = localStorage.getItem(USER_DEFAULT_KEY)
81-
if (d) return JSON.parse(d)
103+
if (d) return migrateMods(JSON.parse(d))
82104
} catch {}
83105
return { pinned: [...FACTORY_CONFIG.pinned], expanded: [...FACTORY_CONFIG.expanded] }
84106
}
@@ -102,7 +124,7 @@ interface DragState {
102124

103125
const ITEM_HEIGHT = 48 // px,每行编辑项高度
104126

105-
export default function Toolbar({ token, sendToWs, scrollToBottom, termRef: _termRef, themeMode, onToggleTheme, onOpenSettings, onUploadFile, onUploadFiles, onOpenFiles, onOpenWorkspace, onFitTerminal, onShowCopySheet, embedded, collapsed: controlledCollapsed, onCollapsedChange }: Props) {
127+
export default function Toolbar({ token, sendToWs, scrollToBottom, termRef: _termRef, themeMode, onToggleTheme, onOpenSettings, onUploadFile, onUploadFiles, onOpenFiles, onOpenWorkspace, onFitTerminal, onShowCopySheet, embedded, collapsed: controlledCollapsed, onCollapsedChange, ctrlMod = 'off', altMod = 'off', onCycleMod }: Props) {
106128
const { t } = useTranslation()
107129
const [config, setConfig] = useState<ToolbarConfig>(loadConfig)
108130
const isControlled = controlledCollapsed !== undefined
@@ -220,7 +242,11 @@ export default function Toolbar({ token, sendToWs, scrollToBottom, termRef: _ter
220242
function updateConfig(next: ToolbarConfig) { setConfig(next); saveConfig(next) }
221243

222244
async function handleKey(key: KeyDef) {
223-
if (key.action === 'scrollToBottom') {
245+
if (key.action === 'modCtrl') {
246+
onCycleMod?.('ctrl')
247+
} else if (key.action === 'modAlt') {
248+
onCycleMod?.('alt')
249+
} else if (key.action === 'scrollToBottom') {
224250
scrollToBottom()
225251
} else if (key.action === 'pasteClipboard') {
226252
// Try clipboard API silently (HTTPS only); fall back to the paste sheet
@@ -371,10 +397,19 @@ export default function Toolbar({ token, sendToWs, scrollToBottom, termRef: _ter
371397
{ids.map(id => {
372398
const key = KEY_MAP[id]
373399
if (!key) return null
400+
// Sticky modifier keys reflect their state: armed = outlined, locked = solid.
401+
const modState = key.action === 'modCtrl' ? ctrlMod : key.action === 'modAlt' ? altMod : undefined
402+
const baseClass = isPC ? keyPCClass : keyClass
403+
const modClass = modState === 'locked'
404+
? ' !bg-nexus-accent !text-white !border-nexus-accent'
405+
: modState === 'armed'
406+
? ' !border-nexus-accent !text-nexus-accent ring-1 ring-nexus-accent'
407+
: ''
374408
return (
375409
<button
376410
key={id}
377-
className={isPC ? keyPCClass : keyClass}
411+
className={baseClass + modClass}
412+
aria-pressed={modState ? modState !== 'off' : undefined}
378413
onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); handleKey(key) }}
379414
>
380415
{key.label}

frontend/src/locales/en/translation.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@
112112
"done": "Done"
113113
},
114114
"toolbarKeys": {
115+
"stickyCtrl": "Ctrl sticky key (tap to arm, then type a letter)",
116+
"stickyAlt": "Alt sticky key (tap to arm, then type a letter)",
115117
"prevHistory": "Previous history",
116118
"nextHistory": "Next history",
117119
"cursorLeft": "Move cursor left",

frontend/src/locales/zh-CN/translation.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@
112112
"done": "完成"
113113
},
114114
"toolbarKeys": {
115+
"stickyCtrl": "Ctrl 粘滞键(点按激活,再敲字母组合)",
116+
"stickyAlt": "Alt 粘滞键(点按激活,再敲字母组合)",
115117
"prevHistory": "上一条历史",
116118
"nextHistory": "下一条历史",
117119
"cursorLeft": "左移光标",

frontend/src/toolbarDefaults.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export interface KeyDef {
33
label: string
44
seq: string
55
desc: string
6-
action?: 'scrollToBottom' | 'pasteClipboard' | 'copyTerminal' | 'fit'
6+
action?: 'scrollToBottom' | 'pasteClipboard' | 'copyTerminal' | 'fit' | 'modCtrl' | 'modAlt'
77
category: 'nav' | 'edit' | 'control' | 'input' | 'ui'
88
}
99

@@ -40,6 +40,11 @@ export const ALL_KEYS: KeyDef[] = [
4040
{ id: 'ctrl-j', label: '^J', seq: '\x0a', desc: 'toolbarKeys.newline', category: 'edit' },
4141
{ id: 'ctrl-z', label: '^Z', seq: '\x1a', desc: 'toolbarKeys.suspend', category: 'edit' },
4242

43+
// === Sticky modifiers (Termux-style: tap to arm, tap again to lock) ===
44+
// seq is empty — handled by action, not sent directly. Applies to the NEXT typed key.
45+
{ id: 'mod-ctrl', label: 'Ctrl', seq: '', desc: 'toolbarKeys.stickyCtrl', action: 'modCtrl', category: 'control' },
46+
{ id: 'mod-alt', label: 'Alt', seq: '', desc: 'toolbarKeys.stickyAlt', action: 'modAlt', category: 'control' },
47+
4348
// === Control (control) ===
4449
{ id: 'esc', label: 'Esc', seq: '\x1b', desc: 'toolbarKeys.escapeVim', category: 'control' },
4550
{ id: 'ctrl-c', label: '^C', seq: '\x03', desc: 'toolbarKeys.cancelInput', category: 'control' },
@@ -68,8 +73,10 @@ export const ALL_KEYS: KeyDef[] = [
6873

6974
// Reorganized factory defaults by priority and category grouping
7075
export const FACTORY_PINNED = [
76+
// Sticky modifiers — tap Ctrl/Alt then type any letter to combine
77+
'mod-ctrl', 'mod-alt',
7178
// Control
72-
'esc',
79+
'esc',
7380
// Navigation
7481
'ctrl-a', 'left', 'up', 'down', 'right', 'ctrl-e',
7582
'backspace',

0 commit comments

Comments
 (0)