Merge pull request #789 from ZacharyZcR/feat/tmux-experience-improvements

feat: improve tmux session management and UX
This commit is contained in:
ZacharyZcR
2026-05-18 04:47:18 +08:00
committed by GitHub
5 changed files with 111 additions and 44 deletions
+37 -22
View File
@@ -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,27 +894,46 @@ 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, sessionName: confirmed,
hostId: session.hostId, hostId: session.hostId,
}); });
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "tmux_session_created", type: "tmux_session_created",
sessionName, sessionName: confirmed,
}), }),
); );
}, 500); })();
} }
} }
}
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; break;
} }
@@ -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 = sessionName; session.tmuxSessionName = confirmed;
} }
sshLogger.info("Created new tmux session", { sshLogger.info("Created new tmux session", {
operation: "tmux_new_session", operation: "tmux_new_session",
sessionName, sessionName: confirmed,
hostId: id, hostId: id,
}); });
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "tmux_session_created", type: "tmux_session_created",
sessionName, sessionName: confirmed,
}), }),
); );
}, 500); runPostShellCommands(0);
// 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;
+27 -1
View File
@@ -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", {
+22
View File
@@ -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")}
+2
View File
@@ -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",
+2
View File
@@ -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": "连接超时",