From 648f4c00db7964a24fefeea6c3cf64e7554d670f Mon Sep 17 00:00:00 2001 From: jleechan Date: Wed, 4 Mar 2026 21:57:01 -0800 Subject: [PATCH] WORKING: Fix split CWD inheritance and bash job notification spam - Pass inherited working directory when creating split panes (panelDirectories fallback to currentDirectory) - Suppress bash job-done "[N] Done ..." notifications in shell integration by toggling job control (set +m / set -m) around background probes - Add integration test for split/tab CWD inheritance Co-Authored-By: Claude Opus 4.6 --- .../cmux-bash-integration.bash | 8 + Sources/Workspace.swift | 10 ++ tests/test_split_cwd_inheritance.py | 170 ++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 tests/test_split_cwd_inheritance.py diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 85027ee4..4576212c 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -82,6 +82,11 @@ _cmux_prompt_command() { [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 + # Suppress bash job-done notifications for background tasks spawned below. + # Without this, every completed async probe prints "[N] Done ..." to the terminal. + local _cmux_old_monitor="${-//[^m]/}" + set +m + local now=$SECONDS local pwd="$PWD" @@ -205,6 +210,9 @@ _cmux_prompt_command() { if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then _cmux_ports_kick fi + + # Restore job control if it was previously enabled. + [[ -n "$_cmux_old_monitor" ]] && set -m } _cmux_install_prompt_command() { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a4a870ea..14c6475e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1956,11 +1956,21 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) + // Inherit working directory: prefer the source panel's reported cwd, + // fall back to the workspace's current directory. + let splitWorkingDirectory: String? = panelDirectories[panelId] + ?? (currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil : currentDirectory) +#if DEBUG + dlog("split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")") +#endif + // Create the new terminal panel. let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, + workingDirectory: splitWorkingDirectory, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel diff --git a/tests/test_split_cwd_inheritance.py b/tests/test_split_cwd_inheritance.py new file mode 100644 index 00000000..bc192939 --- /dev/null +++ b/tests/test_split_cwd_inheritance.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +""" +End-to-end test for split CWD inheritance. + +Verifies that new split panes and new workspace tabs inherit the current +working directory from the source terminal. + +Requires: + - cmux running with allowAll socket mode + - bash shell integration sourced (cmux-bash-integration.bash) + +Run with a tagged instance: + CMUX_TAG= python3 tests/test_split_cwd_inheritance.py +""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux, cmuxError # noqa: E402 + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line or line.startswith(" "): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + data[k.strip()] = v.strip() + return data + + +def _wait_for(predicate, timeout: float, interval: float, label: str): + start = time.time() + last_error: Exception | None = None + while time.time() - start < timeout: + try: + value = predicate() + if value: + return value + except Exception as e: + last_error = e + time.sleep(interval) + extra = "" + if last_error is not None: + extra = f" Last error: {last_error}" + raise AssertionError(f"Timed out waiting for {label}.{extra}") + + +def _wait_for_focused_cwd( + client: cmux, + expected: str, + timeout: float = 12.0, +) -> dict[str, str]: + def pred(): + state = _parse_sidebar_state(client.sidebar_state()) + cwd = state.get("focused_cwd", "") + return state if cwd == expected else None + return _wait_for(pred, timeout=timeout, interval=0.3, label=f"focused_cwd={expected!r}") + + +def _send_cd_and_wait( + client: cmux, + target: str, + timeout: float = 12.0, +) -> dict[str, str]: + """cd to target and wait for sidebar focused_cwd to reflect it.""" + client.send(f"cd {target}\n") + return _wait_for_focused_cwd(client, target, timeout=timeout) + + +def main() -> int: + tag = os.environ.get("CMUX_TAG", "") + + socket_path = None + if tag: + socket_path = f"/tmp/cmux-debug-{tag}.sock" + client = cmux(socket_path=socket_path) + client.connect() + + # Use resolved paths to avoid /tmp -> /private/tmp symlink mismatch on macOS + test_dir_a = str(Path("/tmp/cmux_split_cwd_test_a").resolve()) + test_dir_b = str(Path("/tmp/cmux_split_cwd_test_b").resolve()) + os.makedirs(test_dir_a, exist_ok=True) + os.makedirs(test_dir_b, exist_ok=True) + + passed = 0 + failed = 0 + + def check(name: str, condition: bool, detail: str = ""): + nonlocal passed, failed + if condition: + print(f" PASS {name}") + passed += 1 + else: + print(f" FAIL {name}{': ' + detail if detail else ''}") + failed += 1 + + print("=== Split CWD Inheritance Tests ===") + + # --- Setup: cd to test_dir_a in workspace 1 --- + print(" [setup] cd to test_dir_a and wait for shell integration...") + _send_cd_and_wait(client, test_dir_a) + state = _parse_sidebar_state(client.sidebar_state()) + check("setup: focused_cwd is test_dir_a", state.get("focused_cwd") == test_dir_a, + f"got {state.get('focused_cwd')!r}") + + # --- Test 1: New split inherits test_dir_a --- + print(" [test1] creating right split from test_dir_a...") + split_result = client.new_split("right") + check("split created", bool(split_result)) + + # Wait for the new pane's shell to start and report cwd. + # The split should inherit test_dir_a from the source pane. + time.sleep(4) # wait for new bash to start + run PROMPT_COMMAND + + # Use report_pwd to manually seed the new pane's cwd if shell integration + # hasn't fired yet (macOS /bin/bash can be slow). + # Instead, just wait for sidebar to converge. + try: + state = _wait_for_focused_cwd(client, test_dir_a, timeout=15.0) + check("test1: split inherited test_dir_a", True) + except AssertionError as e: + # Check what we got instead + state = _parse_sidebar_state(client.sidebar_state()) + check("test1: split inherited test_dir_a", False, + f"focused_cwd={state.get('focused_cwd')!r}") + + # --- Test 2: New workspace tab inherits CWD --- + # First cd to test_dir_b so we have a different dir to inherit + print(" [test2] cd to test_dir_b, then creating new workspace tab...") + _send_cd_and_wait(client, test_dir_b) + + tab_result = client.new_tab() + check("new tab created", bool(tab_result)) + + # New workspace should inherit test_dir_b from the previous workspace + time.sleep(4) + try: + state = _wait_for_focused_cwd(client, test_dir_b, timeout=15.0) + check("test2: new workspace inherited test_dir_b", True) + except AssertionError as e: + state = _parse_sidebar_state(client.sidebar_state()) + check("test2: new workspace inherited test_dir_b", False, + f"focused_cwd={state.get('focused_cwd')!r}") + + print(f"\n{passed} passed, {failed} failed") + + client.close() + + # Cleanup + for d in [test_dir_a, test_dir_b]: + try: + os.rmdir(d) + except OSError: + pass + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main())