From 47d660a509309f7779134ff4532e56c296bf1642 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 02:24:29 +0800 Subject: [PATCH] feat: improve tmux session management and UX Replace hardcoded setTimeout timing with exec channel polling for reliable tmux session readiness detection. Add meaningful session naming (termix--), aggressive-resize for multi-client support, and a detach button in the terminal UI. --- src/backend/ssh/terminal.ts | 101 +++++++++++++++----------- src/backend/ssh/tmux-helper.ts | 28 ++++++- src/ui/features/terminal/Terminal.tsx | 22 ++++++ src/ui/locales/en.json | 2 + src/ui/locales/translated/zh_CN.json | 2 + 5 files changed, 111 insertions(+), 44 deletions(-) diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 70c9c6fd..10582603 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -32,7 +32,7 @@ import { sessionManager } from "./terminal-session-manager.js"; import { detectTmux, attachOrCreateTmuxSession, - queryNewestTmuxSession, + waitForTmuxSession, } from "./tmux-helper.js"; class MemoryAgent extends BaseAgent { @@ -879,8 +879,8 @@ wss.on("connection", async (ws: WebSocket, req) => { : null; if (session?.sshStream) { const existingName = tmuxData.sessionName || undefined; - attachOrCreateTmuxSession(session.sshStream, existingName); if (existingName) { + attachOrCreateTmuxSession(session.sshStream, existingName); session.tmuxSessionName = existingName; sshLogger.info("User selected tmux session to attach", { operation: "tmux_user_attach", @@ -894,30 +894,49 @@ wss.on("connection", async (ws: WebSocket, req) => { }), ); } else { - // New session from picker -- query name after startup + const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`; + attachOrCreateTmuxSession(session.sshStream, undefined, newName); const sshConn = session.sshConn; - setTimeout(async () => { - const sessionName = sshConn - ? await queryNewestTmuxSession(sshConn) - : null; - session.tmuxSessionName = sessionName; - sshLogger.info("User requested new tmux session", { - operation: "tmux_user_create", - sessionName, - hostId: session.hostId, - }); - ws.send( - JSON.stringify({ - type: "tmux_session_created", - sessionName, - }), - ); - }, 500); + if (sshConn) { + (async () => { + const confirmed = await waitForTmuxSession(sshConn, newName); + session.tmuxSessionName = confirmed; + sshLogger.info("User requested new tmux session", { + operation: "tmux_user_create", + sessionName: confirmed, + hostId: session.hostId, + }); + ws.send( + JSON.stringify({ + type: "tmux_session_created", + sessionName: confirmed, + }), + ); + })(); + } } } break; } + case "tmux_detach": { + const session = currentSessionId + ? sessionManager.getSession(currentSessionId) + : null; + if (session?.sshConn && session.tmuxSessionName) { + const tmuxName = session.tmuxSessionName; + session.sshStream?.write("\x02d"); + session.tmuxSessionName = null; + sshLogger.info("User detached from tmux session", { + operation: "tmux_user_detach", + sessionName: tmuxName, + hostId: session.hostId, + }); + ws.send(JSON.stringify({ type: "tmux_detached", sessionName: tmuxName })); + } + break; + } + case "totp_response": { const totpData = data as TOTPResponseData; if (keyboardInteractiveFinish && totpData?.code) { @@ -1719,31 +1738,27 @@ wss.on("connection", async (ws: WebSocket, req) => { "tmux is not installed on the remote host. Falling back to standard shell.", }), ); - // tmux unavailable, run commands in plain shell runPostShellCommands(0); } else if (detection.sessions.length === 0) { - attachOrCreateTmuxSession(stream); - // Query the name tmux assigned after a short delay - setTimeout(async () => { - const sessionName = await queryNewestTmuxSession(conn); - const session = sessionManager.getSession(boundSessionId); - if (session) { - session.tmuxSessionName = sessionName; - } - sshLogger.info("Created new tmux session", { - operation: "tmux_new_session", - sessionName, - hostId: id, - }); - ws.send( - JSON.stringify({ - type: "tmux_session_created", - sessionName, - }), - ); - }, 500); - // Wait for tmux to start before running commands inside it - runPostShellCommands(500); + const newName = `termix-${id}-${Date.now().toString(36).slice(-4)}`; + attachOrCreateTmuxSession(stream, undefined, newName); + const confirmed = await waitForTmuxSession(conn, newName); + const session = sessionManager.getSession(boundSessionId); + if (session) { + session.tmuxSessionName = confirmed; + } + sshLogger.info("Created new tmux session", { + operation: "tmux_new_session", + sessionName: confirmed, + hostId: id, + }); + ws.send( + JSON.stringify({ + type: "tmux_session_created", + sessionName: confirmed, + }), + ); + runPostShellCommands(0); } else if (detection.sessions.length === 1) { attachOrCreateTmuxSession(stream, detection.sessions[0].name); const sessionName = detection.sessions[0].name; diff --git a/src/backend/ssh/tmux-helper.ts b/src/backend/ssh/tmux-helper.ts index 2f3023c4..424022c7 100644 --- a/src/backend/ssh/tmux-helper.ts +++ b/src/backend/ssh/tmux-helper.ts @@ -103,6 +103,7 @@ const TMUX_OPTS = `set -gq mouse on` + ` \\; set -gq history-limit 50000` + ` \\; set -gq set-clipboard on` + + ` \\; set -gq aggressive-resize on` + ` \\; set -gq mode-keys vi` + ` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` + ` \\; bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel` + @@ -110,6 +111,29 @@ const TMUX_OPTS = ` 'if -F "#{pane_in_mode}"` + ` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`; +/** + * Wait for a tmux session to appear by polling via exec channel. + * Returns the session name once found, or null on timeout. + */ +export async function waitForTmuxSession( + conn: Client, + sessionName: string, + timeoutMs = 5000, + intervalMs = 100, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await execCommand(conn, `tmux has-session -t ${shellEscape(sessionName)} 2>/dev/null`); + return sessionName; + } catch { + // session not ready yet + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + return null; +} + /** * Write tmux attach or new-session command to the interactive shell stream. * Uses && exit so the shell only closes if tmux started successfully. @@ -117,12 +141,14 @@ const TMUX_OPTS = export function attachOrCreateTmuxSession( stream: ClientChannel, existingSessionName?: string, + newSessionName?: string, ): void { let command: string; if (existingSessionName) { command = `tmux ${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)} && exit\r`; } else { - command = `tmux ${TMUX_OPTS} \\; new-session && exit\r`; + const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : ""; + command = `tmux ${TMUX_OPTS} \\; new-session${nameFlag} && exit\r`; } sshLogger.info("Writing tmux command to shell", { diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index ca87af83..b9dec8d2 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -220,6 +220,7 @@ const TerminalInner = forwardRef( }>; } | null>(null); const tmuxSessionNameRef = useRef(null); + const [isTmuxAttached, setIsTmuxAttached] = useState(false); const tmuxCopyModeHintShownRef = useRef(false); const isVisibleRef = useRef(false); @@ -1533,6 +1534,7 @@ const TerminalInner = forwardRef( const sessionName = typeof msg.sessionName === "string" ? msg.sessionName : ""; tmuxSessionNameRef.current = sessionName || "(active)"; + setIsTmuxAttached(true); addLog({ type: "info", stage: "connection", @@ -1556,6 +1558,10 @@ const TerminalInner = forwardRef( stage: "connection", message: t("terminal.tmuxUnavailable"), }); + } else if (msg.type === "tmux_detached") { + tmuxSessionNameRef.current = null; + setIsTmuxAttached(false); + toast.info(t("terminal.tmuxDetached"), { duration: 3000 }); } else if (msg.type === "connection_log") { if (msg.data) { addLog({ @@ -2513,6 +2519,22 @@ const TerminalInner = forwardRef( }} /> + {isTmuxAttached && isConnected && ( + + )} +