diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index a70f323e71..c44dd34059 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -31,7 +31,7 @@ import * as React from "react"; import { BlockEnv } from "./blockenv"; import { BlockFrameProps } from "./blocktypes"; -function handleHeaderContextMenu( +async function handleHeaderContextMenu( e: React.MouseEvent, blockId: string, viewModel: ViewModel, @@ -56,7 +56,7 @@ function handleHeaderContextMenu( }, }, ]; - const extraItems = viewModel?.getSettingsMenuItems?.(); + const extraItems = await viewModel?.getSettingsMenuItems?.(); if (extraItems && extraItems.length > 0) menu.push({ type: "separator" }, ...extraItems); menu.push( { type: "separator" }, diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 8482be260d..7f264d3a3e 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -618,6 +618,12 @@ export class RpcApiType { return client.wshRpcCall("listalleditableapps", null, opts); } + // command "listtmuxsessions" [call] + ListTmuxSessionsCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "listtmuxsessions", data, opts); + return client.wshRpcCall("listtmuxsessions", data, opts); + } + // command "macosversion" [call] MacOSVersionCommand(client: WshClient, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "macosversion", null, opts); diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index a256929e7d..0fb0962d07 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -42,6 +42,7 @@ import * as React from "react"; import { getBlockingCommand } from "./shellblocking"; import { computeTheme, DefaultTermTheme, isLikelyOnSameHost, trimTerminalSelection } from "./termutil"; import { TermWrap, WebGLSupported } from "./termwrap"; +import { applyTmuxSessionChange, toggleTmuxSession } from "./tmux-session"; export class TermViewModel implements ViewModel { viewType: string; @@ -822,7 +823,7 @@ export class TermViewModel implements ViewModel { }); } - getContextMenuItems(): ContextMenuItem[] { + async getContextMenuItems(): Promise { const menu: ContextMenuItem[] = []; const hasSelection = this.termRef.current?.terminal?.hasSelection(); const selection = hasSelection ? this.termRef.current?.terminal.getSelection() : null; @@ -908,13 +909,69 @@ export class TermViewModel implements ViewModel { menu.push({ type: "separator" }); - const settingsItems = this.getSettingsMenuItems(); + const settingsItems = await this.getSettingsMenuItems(); menu.push(...settingsItems); return menu; } - getSettingsMenuItems(): ContextMenuItem[] { + // Returns the Tmux Sessions submenu for remote blocks, or null when it does not apply. + // Fetches a fresh session snapshot when the settings menu opens and marks the active session as checked. + async getTmuxSessionMenuItems(): Promise { + const blockData = globalStore.get(this.blockAtom); + const connName = blockData?.meta?.connection ?? ""; + if (connName == "" || connName == "local" || connName.startsWith("local:")) { + return null; + } + const curSession = (globalStore.get(getBlockMetaKeyAtom(this.blockId, "term:tmux:session")) ?? "") as string; + let sessions: string[] = []; + try { + // Normalize a null result (RPC connection down) to an empty list so `.includes` below never throws. + sessions = (await RpcApi.ListTmuxSessionsCommand(TabRpcClient, connName)) ?? []; + } catch (e) { + // Keep the settings menu usable when the connection is down, tmux is unavailable, or listing fails. + sessions = []; + } + const submenu: ContextMenuItem[] = []; + // Keep a missing active session visible so the user can still clear the association. + if (curSession != "" && !sessions.includes(curSession)) { + sessions = [curSession, ...sessions]; + } + if (sessions.length == 0) { + submenu.push({ label: "(No tmux sessions)", enabled: false }); + } else { + for (const name of sessions) { + submenu.push({ + label: name, + type: "checkbox", + checked: name == curSession, + click: () => { + fireAndForget(() => this.applyTmuxSession(toggleTmuxSession(curSession, name))); + }, + }); + } + } + return { label: "Tmux Sessions", type: "submenu", submenu }; + } + + // Persists or clears term:tmux:session, then restarts this block controller so the change takes effect immediately. + // An empty session clears the association; a non-empty session associates or switches without stopping remote tmux. + async applyTmuxSession(session: string): Promise { + const oldSession = (globalStore.get(getBlockMetaKeyAtom(this.blockId, "term:tmux:session")) ?? "") as string; + await applyTmuxSessionChange( + oldSession, + session, + async (nextSession) => { + await RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "term:tmux:session": nextSession || null }, + }); + }, + () => this.forceRestartController() + ); + } + + async getSettingsMenuItems(): Promise { const fullConfig = globalStore.get(atoms.fullConfigAtom); const termThemes = fullConfig?.termthemes ?? {}; const termThemeKeys = Object.keys(termThemes); @@ -936,6 +993,10 @@ export class TermViewModel implements ViewModel { }; const fullMenu: ContextMenuItem[] = []; + const tmuxSubmenu = await this.getTmuxSessionMenuItems(); + if (tmuxSubmenu != null) { + fullMenu.push(tmuxSubmenu, { type: "separator" }); + } fullMenu.push({ label: "Split Horizontally", click: () => { diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 67eb5737c6..62091ef40d 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -378,8 +378,10 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - const menuItems = model.getContextMenuItems(); - ContextMenuModel.getInstance().showContextMenu(menuItems, e); + fireAndForget(async () => { + const menuItems = await model.getContextMenuItems(); + ContextMenuModel.getInstance().showContextMenu(menuItems, e); + }); }, [model] ); diff --git a/frontend/app/view/term/tmux-session.test.ts b/frontend/app/view/term/tmux-session.test.ts new file mode 100644 index 0000000000..81ff8a8074 --- /dev/null +++ b/frontend/app/view/term/tmux-session.test.ts @@ -0,0 +1,61 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { applyTmuxSessionChange, toggleTmuxSession } from "./tmux-session"; + +describe("toggleTmuxSession", () => { + it("associates an unchecked session", () => { + expect(toggleTmuxSession("", "mactop-3")).toBe("mactop-3"); + expect(toggleTmuxSession("workspace-8", "mactop-3")).toBe("mactop-3"); + }); + + it("cancels the association when the checked session is selected again", () => { + expect(toggleTmuxSession("mactop-3", "mactop-3")).toBe(""); + }); +}); + +describe("applyTmuxSessionChange", () => { + it("does nothing when the selected session is already active", async () => { + const persistSession = vi.fn(async () => {}); + const restartController = vi.fn(async () => {}); + + const changed = await applyTmuxSessionChange("mactop-3", "mactop-3", persistSession, restartController); + + expect(changed).toBe(false); + expect(persistSession).not.toHaveBeenCalled(); + expect(restartController).not.toHaveBeenCalled(); + }); + + it.each([ + ["cancel", "mactop-3", ""], + ["switch", "mactop-3", "workspace-8"], + ["associate", "", "mactop-3"], + ])("persists and restarts when attempting to %s", async (_operation, currentSession, nextSession) => { + const calls: string[] = []; + const persistSession = vi.fn(async (session: string) => { + calls.push(`persist:${session}`); + }); + const restartController = vi.fn(async () => { + calls.push("restart"); + }); + + const changed = await applyTmuxSessionChange(currentSession, nextSession, persistSession, restartController); + + expect(changed).toBe(true); + expect(calls).toEqual([`persist:${nextSession}`, "restart"]); + }); + + it("does not restart when persisting the session fails", async () => { + const persistSession = vi.fn(async () => { + throw new Error("set meta failed"); + }); + const restartController = vi.fn(async () => {}); + + await expect(applyTmuxSessionChange("mactop-3", "", persistSession, restartController)).rejects.toThrow( + "set meta failed" + ); + expect(restartController).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/app/view/term/tmux-session.ts b/frontend/app/view/term/tmux-session.ts new file mode 100644 index 0000000000..f14ca05607 --- /dev/null +++ b/frontend/app/view/term/tmux-session.ts @@ -0,0 +1,20 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +export async function applyTmuxSessionChange( + currentSession: string, + nextSession: string, + persistSession: (session: string) => Promise, + restartController: () => Promise +): Promise { + if (currentSession === nextSession) { + return false; + } + await persistSession(nextSession); + await restartController(); + return true; +} + +export function toggleTmuxSession(currentSession: string, selectedSession: string): string { + return currentSession === selectedSession ? "" : selectedSession; +} diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 06157e2566..34ac32acf3 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -353,7 +353,7 @@ declare global { isBasicTerm?: (getFn: jotai.Getter) => boolean; // Returns menu items for the settings dropdown. - getSettingsMenuItems?: () => ContextMenuItem[]; + getSettingsMenuItems?: () => ContextMenuItem[] | Promise; // Attempts to give focus to the block, returning true if successful. giveFocus?: () => boolean; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..7f6612ba68 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1190,6 +1190,7 @@ declare global { "term:bellindicator"?: boolean; "term:osc52"?: string; "term:durable"?: boolean; + "term:tmux:session"?: string; "web:zoom"?: number; "web:hidenav"?: boolean; "web:partition"?: string; diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 75f1938e12..aab52dab83 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -490,5 +490,30 @@ func makeSwapToken(ctx context.Context, logCtx context.Context, blockId string, token.Env[k] = v } token.ScriptText = getCustomInitScript(logCtx, blockMeta, remoteName, shellType) + token.ScriptText += buildTmuxAttachScript(blockMeta, remoteName, shellType) return token } + +// buildTmuxAttachScript returns an inline script fragment that auto-attaches to a tmux +// session (or an empty string when it does not apply). It is injected only when the block +// carries a term:tmux:session meta key, the connection is a remote SSH block, and the shell +// is a POSIX-compatible shell (bash/zsh). fish and pwsh use a different syntax, so the +// fragment is skipped for them rather than emitting syntax they cannot parse. +func buildTmuxAttachScript(blockMeta waveobj.MetaMapType, remoteName string, shellType string) string { + tmuxSession := blockMeta.GetString(waveobj.MetaKey_TermTmuxSession, "") + if tmuxSession == "" { + return "" + } + if conncontroller.IsLocalConnName(remoteName) { + return "" + } + if shellType != shellutil.ShellType_bash && shellType != shellutil.ShellType_zsh { + return "" + } + // Wrap the session name in shell single quotes, escaping any inner single quote as '\'' + // so session names cannot break out of the command (injection-safe). + quoted := "'" + strings.ReplaceAll(tmuxSession, "'", "'\\''") + "'" + // Skip when already inside tmux to avoid nesting; fall back gracefully when tmux is not + // installed on the remote; exec replaces the shell so no empty shell lingers behind. + return fmt.Sprintf("\n[ -n \"$TMUX\" ] || ! command -v tmux >/dev/null 2>&1 || exec tmux new -A -t %s\n", quoted) +} diff --git a/pkg/blockcontroller/blockcontroller_test.go b/pkg/blockcontroller/blockcontroller_test.go new file mode 100644 index 0000000000..720f9baefc --- /dev/null +++ b/pkg/blockcontroller/blockcontroller_test.go @@ -0,0 +1,90 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package blockcontroller + +import ( + "strings" + "testing" + + "github.com/wavetermdev/waveterm/pkg/util/shellutil" + "github.com/wavetermdev/waveterm/pkg/waveobj" +) + +// Requirement: auto-associate remote sessions with tmux. +// Acceptance criteria coverage: +// 1. Remote block with term:tmux:session → tmux attach is injected (bash/zsh) +// 2. Local block / block without the meta key → no injection +// 3. Session names are quote-escaped to prevent injection +// 4. Unsupported shells (fish/pwsh) → no injection (syntax would not parse) + +func TestBuildTmuxAttachScript_RemoteConn(t *testing.T) { + meta := waveobj.MetaMapType{ + waveobj.MetaKey_TermTmuxSession: "omlx-11335", + } + for _, shellType := range []string{shellutil.ShellType_bash, shellutil.ShellType_zsh} { + script := buildTmuxAttachScript(meta, "aws:co-gpu", shellType) + if script == "" { + t.Fatalf("expected tmux attach script for remote conn with shell %q", shellType) + } + if !strings.Contains(script, `exec tmux new -A -t 'omlx-11335'`) { + t.Fatalf("unexpected attach command: %q", script) + } + if !strings.Contains(script, `[ -n "$TMUX" ]`) { + t.Fatalf("missing nested tmux guard: %q", script) + } + if !strings.Contains(script, `command -v tmux`) { + t.Fatalf("missing tmux presence guard: %q", script) + } + } +} + +func TestBuildTmuxAttachScript_UnsupportedShell(t *testing.T) { + meta := waveobj.MetaMapType{ + waveobj.MetaKey_TermTmuxSession: "omlx-11335", + } + for _, shellType := range []string{shellutil.ShellType_fish, shellutil.ShellType_pwsh, shellutil.ShellType_unknown} { + if script := buildTmuxAttachScript(meta, "aws:co-gpu", shellType); script != "" { + t.Fatalf("expected no script for shell %q, got %q", shellType, script) + } + } +} + +func TestBuildTmuxAttachScript_LocalConn(t *testing.T) { + meta := waveobj.MetaMapType{ + waveobj.MetaKey_TermTmuxSession: "omlx-11335", + } + for _, connName := range []string{"local", "local:whatever", ""} { + if script := buildTmuxAttachScript(meta, connName, shellutil.ShellType_bash); script != "" { + t.Fatalf("expected no script for local conn %q, got %q", connName, script) + } + } +} + +func TestBuildTmuxAttachScript_NoMetaKey(t *testing.T) { + if script := buildTmuxAttachScript(waveobj.MetaMapType{}, "aws:co-gpu", shellutil.ShellType_bash); script != "" { + t.Fatalf("expected no script when meta key absent, got %q", script) + } + if script := buildTmuxAttachScript(waveobj.MetaMapType{waveobj.MetaKey_TermTmuxSession: ""}, "aws:co-gpu", shellutil.ShellType_bash); script != "" { + t.Fatalf("expected no script when session name empty, got %q", script) + } +} + +func TestBuildTmuxAttachScript_EscapesSessionName(t *testing.T) { + meta := waveobj.MetaMapType{ + waveobj.MetaKey_TermTmuxSession: `evil"; rm -rf /; echo "`, + } + script := buildTmuxAttachScript(meta, "aws:co-gpu", shellutil.ShellType_bash) + // Session names must be single-quoted so inner double quotes / semicolons cannot break shell structure. + if !strings.Contains(script, `-t 'evil"; rm -rf /; echo "'`) { + t.Fatalf("expected single-quoted session name, got: %q", script) + } + // Session names containing single quotes are also safe: inner single quotes escape to '\''. + meta2 := waveobj.MetaMapType{ + waveobj.MetaKey_TermTmuxSession: `a'b`, + } + script2 := buildTmuxAttachScript(meta2, "aws:co-gpu", shellutil.ShellType_bash) + if !strings.Contains(script2, `-t 'a'\''b'`) { + t.Fatalf("expected escaped inner single quote, got: %q", script2) + } +} diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 0ce08099d8..65e19b518f 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -129,6 +129,7 @@ const ( MetaKey_TermBellIndicator = "term:bellindicator" MetaKey_TermOsc52 = "term:osc52" MetaKey_TermDurable = "term:durable" + MetaKey_TermTmuxSession = "term:tmux:session" MetaKey_WebZoom = "web:zoom" MetaKey_WebHideNav = "web:hidenav" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 2280b55d2d..3c57563212 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -133,6 +133,7 @@ type MetaTSType struct { TermBellIndicator *bool `json:"term:bellindicator,omitempty"` TermOsc52 string `json:"term:osc52,omitempty"` TermDurable *bool `json:"term:durable,omitempty"` + TermTmuxSession string `json:"term:tmux:session,omitempty"` WebZoom float64 `json:"web:zoom,omitempty"` WebHideNav *bool `json:"web:hidenav,omitempty"` diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index d5333aec2b..41c93d42fa 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -616,6 +616,12 @@ func ListAllEditableAppsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshr return resp, err } +// command "listtmuxsessions", wshserver.ListTmuxSessionsCommand +func ListTmuxSessionsCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) ([]string, error) { + resp, err := sendRpcRequestCallHelper[[]string](w, "listtmuxsessions", data, opts) + return resp, err +} + // command "macosversion", wshserver.MacOSVersionCommand func MacOSVersionCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "macosversion", nil, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 51e2338ba8..df6a87df87 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -54,6 +54,7 @@ type WshRpcInterface interface { ControllerDestroyCommand(ctx context.Context, blockId string) error ControllerResyncCommand(ctx context.Context, data CommandControllerResyncData) error ControllerAppendOutputCommand(ctx context.Context, data CommandControllerAppendOutputData) error + ListTmuxSessionsCommand(ctx context.Context, connName string) ([]string, error) ResolveIdsCommand(ctx context.Context, data CommandResolveIdsData) (CommandResolveIdsRtnData, error) CreateBlockCommand(ctx context.Context, data CommandCreateBlockData) (waveobj.ORef, error) CreateSubBlockCommand(ctx context.Context, data CommandCreateSubBlockData) (waveobj.ORef, error) diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 38006fd9a8..9aaa12f0be 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -333,6 +333,67 @@ func (ws *WshServer) ControllerInputCommand(ctx context.Context, data wshrpc.Com return blockcontroller.SendInput(data.BlockId, inputUnion) } +// ListTmuxSessionsCommand returns the names of running tmux sessions on the requested connection. +// Local or disconnected connections, a missing tmux executable, and an absent tmux server all return an empty list. +func (ws *WshServer) ListTmuxSessionsCommand(ctx context.Context, connName string) ([]string, error) { + if conncontroller.IsLocalConnName(connName) { + return []string{}, nil + } + opts, err := remote.ParseOpts(connName) + if err != nil { + return nil, fmt.Errorf("error parsing connection name: %w", err) + } + conn := conncontroller.MaybeGetConn(opts) + if conn == nil { + return []string{}, nil + } + client := conn.GetClient() + if client == nil { + return []string{}, nil + } + shellClient := genconn.MakeSSHShellClient(client) + // Use a login shell so the full PATH is available (including locations such as Homebrew's + // /opt/homebrew/bin). Login startup files may write to stdout, so each tmux record is prefixed + // with a sentinel and parsing only accepts sentinel-prefixed lines (see parseTmuxSessionList). + stdout, _, err := genconn.RunSimpleCommand(ctx, shellClient, genconn.CommandSpec{ + Cmd: `bash -lc 'tmux list-sessions -F "` + tmuxSessionSentinel + `#{session_name}" 2>/dev/null' 2>/dev/null || true`, + }) + if err != nil { + return nil, fmt.Errorf("error listing tmux sessions: %w", err) + } + return parseTmuxSessionList(stdout), nil +} + +// tmuxSessionSentinel prefixes each listed session so session names can be told apart from +// arbitrary stdout written by login-shell startup files. +const tmuxSessionSentinel = "WAVETERM_TMUX_SESSION:" + +// parseTmuxSessionList parses `tmux list-sessions` output into session names. It only accepts +// lines prefixed with tmuxSessionSentinel, so login-shell profile output that shares the stdout +// stream cannot leak into the session list; the sentinel and any trailing CRLF are stripped, but +// a session name's own leading/trailing whitespace is preserved (tmux allows it in names). +func parseTmuxSessionList(stdout string) []string { + sessions := []string{} + for _, line := range strings.Split(stdout, "\n") { + sessions = appendTmuxSessionLine(sessions, line, tmuxSessionSentinel) + } + return sessions +} + +// appendTmuxSessionLine appends the session name extracted from a single output line, or leaves +// the slice unchanged when the line is not a valid sentinel-prefixed record. +func appendTmuxSessionLine(sessions []string, line, sentinel string) []string { + line = strings.TrimSuffix(line, "\r") + if !strings.HasPrefix(line, sentinel) { + return sessions + } + name := strings.TrimPrefix(line, sentinel) + if name != "" { + sessions = append(sessions, name) + } + return sessions +} + func (ws *WshServer) ControllerAppendOutputCommand(ctx context.Context, data wshrpc.CommandControllerAppendOutputData) error { outputBuf := make([]byte, base64.StdEncoding.DecodedLen(len(data.Data64))) nw, err := base64.StdEncoding.Decode(outputBuf, []byte(data.Data64)) diff --git a/pkg/wshrpc/wshserver/wshserver_test.go b/pkg/wshrpc/wshserver/wshserver_test.go new file mode 100644 index 0000000000..f949de126b --- /dev/null +++ b/pkg/wshrpc/wshserver/wshserver_test.go @@ -0,0 +1,87 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshserver + +import ( + "reflect" + "testing" +) + +func TestParseTmuxSessionList(t *testing.T) { + const s = tmuxSessionSentinel + tests := []struct { + name string + stdout string + want []string + }{ + { + name: "empty output", + stdout: "", + want: []string{}, + }, + { + name: "single session", + stdout: s + "mactop\n", + want: []string{"mactop"}, + }, + { + name: "multiple sessions", + stdout: s + "mactop\n" + s + "omlx-11335\n" + s + "omlx-11336\n", + want: []string{"mactop", "omlx-11335", "omlx-11336"}, + }, + { + name: "blank lines are discarded", + stdout: "\n" + s + "mactop\n\n" + s + "omlx-11335\n\n", + want: []string{"mactop", "omlx-11335"}, + }, + { + name: "CRLF line endings", + stdout: s + "mactop\r\n" + s + "omlx-11335\r\n", + want: []string{"mactop", "omlx-11335"}, + }, + { + name: "session name with spaces", + stdout: s + "my session with spaces\n", + want: []string{"my session with spaces"}, + }, + { + name: "session name with leading/trailing whitespace is preserved", + stdout: s + " padded-name \n", + want: []string{" padded-name "}, + }, + { + name: "ignores unsentineled lines", + stdout: "some other output\n" + s + "mactop\n", + want: []string{"mactop"}, + }, + { + name: "login shell profile output before tmux records", + stdout: "Last login: Thu Aug 26 14:00:00 2026 on ttys000\nWelcome to bash\n" + s + "mactop\n" + s + "omlx-11335\n", + want: []string{"mactop", "omlx-11335"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTmuxSessionList(tt.stdout) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("parseTmuxSessionList(%q) = %#v, want %#v", tt.stdout, got, tt.want) + } + }) + } +} + +func TestAppendTmuxSessionLine(t *testing.T) { + const s = tmuxSessionSentinel + var sessions []string + sessions = appendTmuxSessionLine(sessions, "not a tmux record", s) + sessions = appendTmuxSessionLine(sessions, s+"mactop", s) + sessions = appendTmuxSessionLine(sessions, s+" spaced name ", s) // name whitespace preserved + sessions = appendTmuxSessionLine(sessions, " "+s+"leading-space-sentinel", s) // sentinel not at line start → ignored + sessions = appendTmuxSessionLine(sessions, s, s) // sentinel with no name + sessions = appendTmuxSessionLine(sessions, "", s) + sessions = appendTmuxSessionLine(sessions, s+"crlf\r", s) // trailing CR stripped + if !reflect.DeepEqual(sessions, []string{"mactop", " spaced name ", "crlf"}) { + t.Fatalf("appendTmuxSessionLine produced %#v", sessions) + } +}