Skip to content

Commit 2056515

Browse files
committed
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-<hostId>-<rand>), aggressive-resize for multi-client support, and a detach button in the terminal UI.
1 parent 99d4eca commit 2056515

5 files changed

Lines changed: 111 additions & 44 deletions

File tree

src/backend/ssh/terminal.ts

Lines changed: 58 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { sessionManager } from "./terminal-session-manager.js";
2222
import {
2323
detectTmux,
2424
attachOrCreateTmuxSession,
25-
queryNewestTmuxSession,
25+
waitForTmuxSession,
2626
} from "./tmux-helper.js";
2727

2828
async function performPortKnocking(
@@ -806,8 +806,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
806806
: null;
807807
if (session?.sshStream) {
808808
const existingName = tmuxData.sessionName || undefined;
809-
attachOrCreateTmuxSession(session.sshStream, existingName);
810809
if (existingName) {
810+
attachOrCreateTmuxSession(session.sshStream, existingName);
811811
session.tmuxSessionName = existingName;
812812
sshLogger.info("User selected tmux session to attach", {
813813
operation: "tmux_user_attach",
@@ -821,30 +821,49 @@ wss.on("connection", async (ws: WebSocket, req) => {
821821
}),
822822
);
823823
} else {
824-
// New session from picker -- query name after startup
824+
const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`;
825+
attachOrCreateTmuxSession(session.sshStream, undefined, newName);
825826
const sshConn = session.sshConn;
826-
setTimeout(async () => {
827-
const sessionName = sshConn
828-
? await queryNewestTmuxSession(sshConn)
829-
: null;
830-
session.tmuxSessionName = sessionName;
831-
sshLogger.info("User requested new tmux session", {
832-
operation: "tmux_user_create",
833-
sessionName,
834-
hostId: session.hostId,
835-
});
836-
ws.send(
837-
JSON.stringify({
838-
type: "tmux_session_created",
839-
sessionName,
840-
}),
841-
);
842-
}, 500);
827+
if (sshConn) {
828+
(async () => {
829+
const confirmed = await waitForTmuxSession(sshConn, newName);
830+
session.tmuxSessionName = confirmed;
831+
sshLogger.info("User requested new tmux session", {
832+
operation: "tmux_user_create",
833+
sessionName: confirmed,
834+
hostId: session.hostId,
835+
});
836+
ws.send(
837+
JSON.stringify({
838+
type: "tmux_session_created",
839+
sessionName: confirmed,
840+
}),
841+
);
842+
})();
843+
}
843844
}
844845
}
845846
break;
846847
}
847848

849+
case "tmux_detach": {
850+
const session = currentSessionId
851+
? sessionManager.getSession(currentSessionId)
852+
: null;
853+
if (session?.sshConn && session.tmuxSessionName) {
854+
const tmuxName = session.tmuxSessionName;
855+
session.sshStream?.write("\x02d");
856+
session.tmuxSessionName = null;
857+
sshLogger.info("User detached from tmux session", {
858+
operation: "tmux_user_detach",
859+
sessionName: tmuxName,
860+
hostId: session.hostId,
861+
});
862+
ws.send(JSON.stringify({ type: "tmux_detached", sessionName: tmuxName }));
863+
}
864+
break;
865+
}
866+
848867
case "totp_response": {
849868
const totpData = data as TOTPResponseData;
850869
if (keyboardInteractiveFinish && totpData?.code) {
@@ -1646,31 +1665,27 @@ wss.on("connection", async (ws: WebSocket, req) => {
16461665
"tmux is not installed on the remote host. Falling back to standard shell.",
16471666
}),
16481667
);
1649-
// tmux unavailable, run commands in plain shell
16501668
runPostShellCommands(0);
16511669
} else if (detection.sessions.length === 0) {
1652-
attachOrCreateTmuxSession(stream);
1653-
// Query the name tmux assigned after a short delay
1654-
setTimeout(async () => {
1655-
const sessionName = await queryNewestTmuxSession(conn);
1656-
const session = sessionManager.getSession(boundSessionId);
1657-
if (session) {
1658-
session.tmuxSessionName = sessionName;
1659-
}
1660-
sshLogger.info("Created new tmux session", {
1661-
operation: "tmux_new_session",
1662-
sessionName,
1663-
hostId: id,
1664-
});
1665-
ws.send(
1666-
JSON.stringify({
1667-
type: "tmux_session_created",
1668-
sessionName,
1669-
}),
1670-
);
1671-
}, 500);
1672-
// Wait for tmux to start before running commands inside it
1673-
runPostShellCommands(500);
1670+
const newName = `termix-${id}-${Date.now().toString(36).slice(-4)}`;
1671+
attachOrCreateTmuxSession(stream, undefined, newName);
1672+
const confirmed = await waitForTmuxSession(conn, newName);
1673+
const session = sessionManager.getSession(boundSessionId);
1674+
if (session) {
1675+
session.tmuxSessionName = confirmed;
1676+
}
1677+
sshLogger.info("Created new tmux session", {
1678+
operation: "tmux_new_session",
1679+
sessionName: confirmed,
1680+
hostId: id,
1681+
});
1682+
ws.send(
1683+
JSON.stringify({
1684+
type: "tmux_session_created",
1685+
sessionName: confirmed,
1686+
}),
1687+
);
1688+
runPostShellCommands(0);
16741689
} else if (detection.sessions.length === 1) {
16751690
attachOrCreateTmuxSession(stream, detection.sessions[0].name);
16761691
const sessionName = detection.sessions[0].name;

src/backend/ssh/tmux-helper.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,26 +103,52 @@ const TMUX_OPTS =
103103
`set -gq mouse on` +
104104
` \\; set -gq history-limit 50000` +
105105
` \\; set -gq set-clipboard on` +
106+
` \\; set -gq aggressive-resize on` +
106107
` \\; set -gq mode-keys vi` +
107108
` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` +
108109
` \\; bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel` +
109110
` \\; set-hook -g pane-mode-changed` +
110111
` 'if -F "#{pane_in_mode}"` +
111112
` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`;
112113

114+
/**
115+
* Wait for a tmux session to appear by polling via exec channel.
116+
* Returns the session name once found, or null on timeout.
117+
*/
118+
export async function waitForTmuxSession(
119+
conn: Client,
120+
sessionName: string,
121+
timeoutMs = 5000,
122+
intervalMs = 100,
123+
): Promise<string | null> {
124+
const deadline = Date.now() + timeoutMs;
125+
while (Date.now() < deadline) {
126+
try {
127+
await execCommand(conn, `tmux has-session -t ${shellEscape(sessionName)} 2>/dev/null`);
128+
return sessionName;
129+
} catch {
130+
// session not ready yet
131+
}
132+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
133+
}
134+
return null;
135+
}
136+
113137
/**
114138
* Write tmux attach or new-session command to the interactive shell stream.
115139
* Uses && exit so the shell only closes if tmux started successfully.
116140
*/
117141
export function attachOrCreateTmuxSession(
118142
stream: ClientChannel,
119143
existingSessionName?: string,
144+
newSessionName?: string,
120145
): void {
121146
let command: string;
122147
if (existingSessionName) {
123148
command = `tmux ${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)} && exit\r`;
124149
} else {
125-
command = `tmux ${TMUX_OPTS} \\; new-session && exit\r`;
150+
const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : "";
151+
command = `tmux ${TMUX_OPTS} \\; new-session${nameFlag} && exit\r`;
126152
}
127153

128154
sshLogger.info("Writing tmux command to shell", {

src/ui/features/terminal/Terminal.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
220220
}>;
221221
} | null>(null);
222222
const tmuxSessionNameRef = useRef<string | null>(null);
223+
const [isTmuxAttached, setIsTmuxAttached] = useState(false);
223224
const tmuxCopyModeHintShownRef = useRef(false);
224225

225226
const isVisibleRef = useRef<boolean>(false);
@@ -1533,6 +1534,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
15331534
const sessionName =
15341535
typeof msg.sessionName === "string" ? msg.sessionName : "";
15351536
tmuxSessionNameRef.current = sessionName || "(active)";
1537+
setIsTmuxAttached(true);
15361538
addLog({
15371539
type: "info",
15381540
stage: "connection",
@@ -1556,6 +1558,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
15561558
stage: "connection",
15571559
message: t("terminal.tmuxUnavailable"),
15581560
});
1561+
} else if (msg.type === "tmux_detached") {
1562+
tmuxSessionNameRef.current = null;
1563+
setIsTmuxAttached(false);
1564+
toast.info(t("terminal.tmuxDetached"), { duration: 3000 });
15591565
} else if (msg.type === "connection_log") {
15601566
if (msg.data) {
15611567
addLog({
@@ -2508,6 +2514,22 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
25082514
}}
25092515
/>
25102516

2517+
{isTmuxAttached && isConnected && (
2518+
<button
2519+
onClick={() => {
2520+
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
2521+
webSocketRef.current.send(
2522+
JSON.stringify({ type: "tmux_detach" }),
2523+
);
2524+
}
2525+
}}
2526+
title={t("terminal.tmuxDetach")}
2527+
className="absolute top-2 right-2 z-[110] px-2 py-1 text-xs rounded bg-black/60 text-white/70 hover:text-white hover:bg-black/80 transition-colors"
2528+
>
2529+
tmux:detach
2530+
</button>
2531+
)}
2532+
25112533
<SimpleLoader
25122534
visible={isConnecting && !isConnectionLogExpanded}
25132535
message={t("terminal.connecting")}

src/ui/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1872,6 +1872,8 @@
18721872
"tmuxTimeDays": "{{count}}d ago",
18731873
"tmuxCreateNew": "Start new session",
18741874
"tmuxCopyHint": "Adjust selection and press Enter to copy to clipboard",
1875+
"tmuxDetach": "Detach from tmux session",
1876+
"tmuxDetached": "Detached from tmux session",
18751877
"maxReconnectAttemptsReached": "Maximum reconnection attempts reached",
18761878
"connectionLost": "Connection lost",
18771879
"reconnect": "Reconnect",

src/ui/locales/translated/zh_CN.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1586,6 +1586,8 @@
15861586
"tmuxTimeDays": "{{count}}d ago",
15871587
"tmuxCreateNew": "开始新会话",
15881588
"tmuxCopyHint": "调整选区并按回车键复制到剪贴板",
1589+
"tmuxDetach": "从 tmux 会话分离",
1590+
"tmuxDetached": "已从 tmux 会话分离",
15891591
"maxReconnectAttemptsReached": "已达到最大重连尝试次数",
15901592
"closeTab": "关闭",
15911593
"connectionTimeout": "连接超时",

0 commit comments

Comments
 (0)