mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
Merge pull request #789 from ZacharyZcR/feat/tmux-experience-improvements
feat: improve tmux session management and UX
This commit is contained in:
+58
-43
@@ -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;
|
||||
|
||||
@@ -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<string | null> {
|
||||
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", {
|
||||
|
||||
@@ -220,6 +220,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}>;
|
||||
} | null>(null);
|
||||
const tmuxSessionNameRef = useRef<string | null>(null);
|
||||
const [isTmuxAttached, setIsTmuxAttached] = useState(false);
|
||||
const tmuxCopyModeHintShownRef = useRef(false);
|
||||
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
@@ -1533,6 +1534,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
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<TerminalHandle, SSHTerminalProps>(
|
||||
}}
|
||||
/>
|
||||
|
||||
{isTmuxAttached && isConnected && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "tmux_detach" }),
|
||||
);
|
||||
}
|
||||
}}
|
||||
title={t("terminal.tmuxDetach")}
|
||||
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"
|
||||
>
|
||||
tmux:detach
|
||||
</button>
|
||||
)}
|
||||
|
||||
<SimpleLoader
|
||||
visible={isConnecting && !isConnectionLogExpanded}
|
||||
message={t("terminal.connecting")}
|
||||
|
||||
@@ -1872,6 +1872,8 @@
|
||||
"tmuxTimeDays": "{{count}}d ago",
|
||||
"tmuxCreateNew": "Start new session",
|
||||
"tmuxCopyHint": "Adjust selection and press Enter to copy to clipboard",
|
||||
"tmuxDetach": "Detach from tmux session",
|
||||
"tmuxDetached": "Detached from tmux session",
|
||||
"maxReconnectAttemptsReached": "Maximum reconnection attempts reached",
|
||||
"connectionLost": "Connection lost",
|
||||
"reconnect": "Reconnect",
|
||||
|
||||
@@ -1586,6 +1586,8 @@
|
||||
"tmuxTimeDays": "{{count}}d ago",
|
||||
"tmuxCreateNew": "开始新会话",
|
||||
"tmuxCopyHint": "调整选区并按回车键复制到剪贴板",
|
||||
"tmuxDetach": "从 tmux 会话分离",
|
||||
"tmuxDetached": "已从 tmux 会话分离",
|
||||
"maxReconnectAttemptsReached": "已达到最大重连尝试次数",
|
||||
"closeTab": "关闭",
|
||||
"connectionTimeout": "连接超时",
|
||||
|
||||
Reference in New Issue
Block a user