mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +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 {
|
import {
|
||||||
detectTmux,
|
detectTmux,
|
||||||
attachOrCreateTmuxSession,
|
attachOrCreateTmuxSession,
|
||||||
queryNewestTmuxSession,
|
waitForTmuxSession,
|
||||||
} from "./tmux-helper.js";
|
} from "./tmux-helper.js";
|
||||||
|
|
||||||
class MemoryAgent extends BaseAgent {
|
class MemoryAgent extends BaseAgent {
|
||||||
@@ -879,8 +879,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
: null;
|
: null;
|
||||||
if (session?.sshStream) {
|
if (session?.sshStream) {
|
||||||
const existingName = tmuxData.sessionName || undefined;
|
const existingName = tmuxData.sessionName || undefined;
|
||||||
attachOrCreateTmuxSession(session.sshStream, existingName);
|
|
||||||
if (existingName) {
|
if (existingName) {
|
||||||
|
attachOrCreateTmuxSession(session.sshStream, existingName);
|
||||||
session.tmuxSessionName = existingName;
|
session.tmuxSessionName = existingName;
|
||||||
sshLogger.info("User selected tmux session to attach", {
|
sshLogger.info("User selected tmux session to attach", {
|
||||||
operation: "tmux_user_attach",
|
operation: "tmux_user_attach",
|
||||||
@@ -894,30 +894,49 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} 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;
|
const sshConn = session.sshConn;
|
||||||
setTimeout(async () => {
|
if (sshConn) {
|
||||||
const sessionName = sshConn
|
(async () => {
|
||||||
? await queryNewestTmuxSession(sshConn)
|
const confirmed = await waitForTmuxSession(sshConn, newName);
|
||||||
: null;
|
session.tmuxSessionName = confirmed;
|
||||||
session.tmuxSessionName = sessionName;
|
sshLogger.info("User requested new tmux session", {
|
||||||
sshLogger.info("User requested new tmux session", {
|
operation: "tmux_user_create",
|
||||||
operation: "tmux_user_create",
|
sessionName: confirmed,
|
||||||
sessionName,
|
hostId: session.hostId,
|
||||||
hostId: session.hostId,
|
});
|
||||||
});
|
ws.send(
|
||||||
ws.send(
|
JSON.stringify({
|
||||||
JSON.stringify({
|
type: "tmux_session_created",
|
||||||
type: "tmux_session_created",
|
sessionName: confirmed,
|
||||||
sessionName,
|
}),
|
||||||
}),
|
);
|
||||||
);
|
})();
|
||||||
}, 500);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
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": {
|
case "totp_response": {
|
||||||
const totpData = data as TOTPResponseData;
|
const totpData = data as TOTPResponseData;
|
||||||
if (keyboardInteractiveFinish && totpData?.code) {
|
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 is not installed on the remote host. Falling back to standard shell.",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// tmux unavailable, run commands in plain shell
|
|
||||||
runPostShellCommands(0);
|
runPostShellCommands(0);
|
||||||
} else if (detection.sessions.length === 0) {
|
} else if (detection.sessions.length === 0) {
|
||||||
attachOrCreateTmuxSession(stream);
|
const newName = `termix-${id}-${Date.now().toString(36).slice(-4)}`;
|
||||||
// Query the name tmux assigned after a short delay
|
attachOrCreateTmuxSession(stream, undefined, newName);
|
||||||
setTimeout(async () => {
|
const confirmed = await waitForTmuxSession(conn, newName);
|
||||||
const sessionName = await queryNewestTmuxSession(conn);
|
const session = sessionManager.getSession(boundSessionId);
|
||||||
const session = sessionManager.getSession(boundSessionId);
|
if (session) {
|
||||||
if (session) {
|
session.tmuxSessionName = confirmed;
|
||||||
session.tmuxSessionName = sessionName;
|
}
|
||||||
}
|
sshLogger.info("Created new tmux session", {
|
||||||
sshLogger.info("Created new tmux session", {
|
operation: "tmux_new_session",
|
||||||
operation: "tmux_new_session",
|
sessionName: confirmed,
|
||||||
sessionName,
|
hostId: id,
|
||||||
hostId: id,
|
});
|
||||||
});
|
ws.send(
|
||||||
ws.send(
|
JSON.stringify({
|
||||||
JSON.stringify({
|
type: "tmux_session_created",
|
||||||
type: "tmux_session_created",
|
sessionName: confirmed,
|
||||||
sessionName,
|
}),
|
||||||
}),
|
);
|
||||||
);
|
runPostShellCommands(0);
|
||||||
}, 500);
|
|
||||||
// Wait for tmux to start before running commands inside it
|
|
||||||
runPostShellCommands(500);
|
|
||||||
} else if (detection.sessions.length === 1) {
|
} else if (detection.sessions.length === 1) {
|
||||||
attachOrCreateTmuxSession(stream, detection.sessions[0].name);
|
attachOrCreateTmuxSession(stream, detection.sessions[0].name);
|
||||||
const sessionName = detection.sessions[0].name;
|
const sessionName = detection.sessions[0].name;
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ const TMUX_OPTS =
|
|||||||
`set -gq mouse on` +
|
`set -gq mouse on` +
|
||||||
` \\; set -gq history-limit 50000` +
|
` \\; set -gq history-limit 50000` +
|
||||||
` \\; set -gq set-clipboard on` +
|
` \\; set -gq set-clipboard on` +
|
||||||
|
` \\; set -gq aggressive-resize on` +
|
||||||
` \\; set -gq mode-keys vi` +
|
` \\; set -gq mode-keys vi` +
|
||||||
` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` +
|
` \\; 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` +
|
` \\; 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}"` +
|
` 'if -F "#{pane_in_mode}"` +
|
||||||
` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`;
|
` "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.
|
* Write tmux attach or new-session command to the interactive shell stream.
|
||||||
* Uses && exit so the shell only closes if tmux started successfully.
|
* Uses && exit so the shell only closes if tmux started successfully.
|
||||||
@@ -117,12 +141,14 @@ const TMUX_OPTS =
|
|||||||
export function attachOrCreateTmuxSession(
|
export function attachOrCreateTmuxSession(
|
||||||
stream: ClientChannel,
|
stream: ClientChannel,
|
||||||
existingSessionName?: string,
|
existingSessionName?: string,
|
||||||
|
newSessionName?: string,
|
||||||
): void {
|
): void {
|
||||||
let command: string;
|
let command: string;
|
||||||
if (existingSessionName) {
|
if (existingSessionName) {
|
||||||
command = `tmux ${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)} && exit\r`;
|
command = `tmux ${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)} && exit\r`;
|
||||||
} else {
|
} 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", {
|
sshLogger.info("Writing tmux command to shell", {
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}>;
|
}>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const tmuxSessionNameRef = useRef<string | null>(null);
|
const tmuxSessionNameRef = useRef<string | null>(null);
|
||||||
|
const [isTmuxAttached, setIsTmuxAttached] = useState(false);
|
||||||
const tmuxCopyModeHintShownRef = useRef(false);
|
const tmuxCopyModeHintShownRef = useRef(false);
|
||||||
|
|
||||||
const isVisibleRef = useRef<boolean>(false);
|
const isVisibleRef = useRef<boolean>(false);
|
||||||
@@ -1533,6 +1534,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const sessionName =
|
const sessionName =
|
||||||
typeof msg.sessionName === "string" ? msg.sessionName : "";
|
typeof msg.sessionName === "string" ? msg.sessionName : "";
|
||||||
tmuxSessionNameRef.current = sessionName || "(active)";
|
tmuxSessionNameRef.current = sessionName || "(active)";
|
||||||
|
setIsTmuxAttached(true);
|
||||||
addLog({
|
addLog({
|
||||||
type: "info",
|
type: "info",
|
||||||
stage: "connection",
|
stage: "connection",
|
||||||
@@ -1556,6 +1558,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
stage: "connection",
|
stage: "connection",
|
||||||
message: t("terminal.tmuxUnavailable"),
|
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") {
|
} else if (msg.type === "connection_log") {
|
||||||
if (msg.data) {
|
if (msg.data) {
|
||||||
addLog({
|
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
|
<SimpleLoader
|
||||||
visible={isConnecting && !isConnectionLogExpanded}
|
visible={isConnecting && !isConnectionLogExpanded}
|
||||||
message={t("terminal.connecting")}
|
message={t("terminal.connecting")}
|
||||||
|
|||||||
@@ -1872,6 +1872,8 @@
|
|||||||
"tmuxTimeDays": "{{count}}d ago",
|
"tmuxTimeDays": "{{count}}d ago",
|
||||||
"tmuxCreateNew": "Start new session",
|
"tmuxCreateNew": "Start new session",
|
||||||
"tmuxCopyHint": "Adjust selection and press Enter to copy to clipboard",
|
"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",
|
"maxReconnectAttemptsReached": "Maximum reconnection attempts reached",
|
||||||
"connectionLost": "Connection lost",
|
"connectionLost": "Connection lost",
|
||||||
"reconnect": "Reconnect",
|
"reconnect": "Reconnect",
|
||||||
|
|||||||
@@ -1586,6 +1586,8 @@
|
|||||||
"tmuxTimeDays": "{{count}}d ago",
|
"tmuxTimeDays": "{{count}}d ago",
|
||||||
"tmuxCreateNew": "开始新会话",
|
"tmuxCreateNew": "开始新会话",
|
||||||
"tmuxCopyHint": "调整选区并按回车键复制到剪贴板",
|
"tmuxCopyHint": "调整选区并按回车键复制到剪贴板",
|
||||||
|
"tmuxDetach": "从 tmux 会话分离",
|
||||||
|
"tmuxDetached": "已从 tmux 会话分离",
|
||||||
"maxReconnectAttemptsReached": "已达到最大重连尝试次数",
|
"maxReconnectAttemptsReached": "已达到最大重连尝试次数",
|
||||||
"closeTab": "关闭",
|
"closeTab": "关闭",
|
||||||
"connectionTimeout": "连接超时",
|
"connectionTimeout": "连接超时",
|
||||||
|
|||||||
Reference in New Issue
Block a user