diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 3caf5be8..cd76ac3e 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1707,6 +1707,515 @@ } } }, + "%lld clients" : { + "comment" : "zmx attached client count", + "localizations" : { + "ar" : { + "variations" : { + "plural" : { + "zero" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld عميل" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "عميل واحد" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "عميلان" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld عملاء" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld عميلًا" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld عميل" + } + } + } + } + }, + "ca" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld client" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clients" + } + } + } + } + }, + "cs" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klient" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienti" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienta" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klientů" + } + } + } + } + }, + "da" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klient" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienter" + } + } + } + } + }, + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Client" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Clients" + } + } + } + } + }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld cliente" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clientes" + } + } + } + } + }, + "fi" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld asiakas" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld asiakasta" + } + } + } + } + }, + "fr" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld client" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clients" + } + } + } + } + }, + "he" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "לקוח אחד" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "שני לקוחות" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld לקוחות" + } + } + } + } + }, + "hu" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kliens" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kliens" + } + } + } + } + }, + "it" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld client" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld client" + } + } + } + } + }, + "ja" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldクライアント" + } + } + } + } + }, + "ko" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "클라이언트 %lld개" + } + } + } + } + }, + "nb" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klient" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienter" + } + } + } + } + }, + "nl" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld client" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clients" + } + } + } + } + }, + "pl" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klient" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienci" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klientów" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienta" + } + } + } + } + }, + "pt-BR" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld cliente" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clientes" + } + } + } + } + }, + "pt-PT" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld cliente" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clientes" + } + } + } + } + }, + "ro" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld client" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld clienți" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld de clienți" + } + } + } + } + }, + "sl" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld odjemalec" + } + }, + "two" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld odjemalca" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld odjemalci" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld odjemalcev" + } + } + } + } + }, + "sv" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klient" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld klienter" + } + } + } + } + }, + "uk" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld клієнт" + } + }, + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld клієнти" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld клієнтів" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld клієнта" + } + } + } + } + }, + "vi" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld máy khách" + } + } + } + } + }, + "zh-Hans" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个客户端" + } + } + } + } + }, + "zh-Hant" : { + "variations" : { + "plural" : { + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 個用戶端" + } + } + } + } + } + } + }, "-/%llu" : { "localizations" : { "ar" : { @@ -2170,6 +2679,2009 @@ } } }, + "Attach to or create zmx session \"%@\" on connect" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الاتصال بجلسة zmx \"%@\" أو إنشاؤها عند الاتصال" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecta o crea la sessió zmx \"%@\" en connectar" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Připojit se k relaci zmx \"%@\" nebo ji vytvořit při připojení" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilslut eller opret zmx-session \"%@\" ved forbindelse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-Sitzung \"%@\" beim Verbinden anhängen oder erstellen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar o crear sesión zmx \"%@\" al conectar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liity zmx-istuntoon \"%@\" tai luo se yhdistettäessä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se connecter à la session zmx \"%@\" ou la créer lors de la connexion" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התחברות או יצירת הפעלת zmx \"%@\" בעת חיבור" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Csatlakozás a(z) \"%@\" zmx-munkamenethez, vagy létrehozása kapcsolódáskor" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collega o crea la sessione zmx \"%@\" alla connessione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "接続時にzmxセッション「%@」に接続または作成" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결 시 zmx 세션 \"%@\"에 연결하거나 생성" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koble til eller opprett zmx-økt \"%@\" ved tilkobling" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbinden met of zmx-sessie \"%@\" aanmaken bij verbinding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podłącz do sesji zmx \"%@\" lub utwórz ją przy połączeniu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar ou criar sessão zmx \"%@\" ao conectar" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ligar ou criar sessão zmx \"%@\" ao ligar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectare la sau creare sesiune zmx \"%@\" la conectare" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pripni se na sejo zmx \"%@\" ali jo ustvari ob povezavi" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anslut till eller skapa zmx-session \"%@\" vid anslutning" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приєднатися до сеансу zmx \"%@\" або створити його при підключенні" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối hoặc tạo phiên zmx \"%@\" khi kết nối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接时附加到或创建 zmx 会话「%@」" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "連線時附加到或建立 zmx 工作階段「%@」" + } + } + } + }, + "Created %@" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أُنشئت %@" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creada %@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vytvořeno %@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oprettet %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erstellt %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creada %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luotu %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Créée %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נוצר %@" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Létrehozva %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creata %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "作成 %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "생성됨 %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opprettet %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aangemaakt %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utworzono %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Criada %@" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Criada %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creată %@" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustvarjeno %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skapad %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Створено %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã tạo %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建于 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "建立於 %@" + } + } + }, + "comment" : "zmx session age, e.g. 'Created 2 hr. ago'" + }, + "Discover zmx Sessions" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اكتشاف جلسات zmx" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descobreix sessions d'zmx" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zjistit relace zmx" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdag zmx-sessioner" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-Sitzungen entdecken" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descubrir sesiones de zmx" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etsi zmx-istuntoja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Découvrir les sessions zmx" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "גלה סשנים של zmx" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-munkamenetek felderítése" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scopri sessioni zmx" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmxセッションを検出" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 세션 검색" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oppdag zmx-økter" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-sessies ontdekken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wykryj sesje zmx" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descobrir sessões zmx" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descobrir sessões zmx" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descoperă sesiuni zmx" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odkrij seje zmx" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Identifiera zmx-sessioner" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виявити сеанси zmx" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khám phá phiên zmx" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发现 zmx 会话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "探索 zmx 工作階段" + } + } + } + }, + "Multiplexer Tips" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نصائح معددات الطرفية" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consells de multiplexors" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipy pro multiplexery" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplekser-tip" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplexer-Tipps" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consejos de multiplexores" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplekservinkit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conseils multiplexeurs" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "טיפים למרבבים" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplexer tippek" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consigli sui multiplexer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "マルチプレクサのヒント" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "멀티플렉서 팁" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplekser-tips" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplexer-tips" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wskazówki multiplekserów" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dicas de multiplexadores" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sugestões de multiplexadores" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sfaturi multiplexoare" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nasveti za multiplekserje" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplexertips" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поради щодо мультиплексорів" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mẹo bộ ghép kênh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "终端复用器提示" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "終端多工器提示" + } + } + } + }, + "Overrides the entire zmx command, including the session name. Leave empty to use the default." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتجاوز أمر zmx بالكامل، بما في ذلك اسم الجلسة. اتركه فارغًا لاستخدام الأمر الافتراضي." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Substitueix tota l'ordre d'zmx, inclòs el nom de la sessió. Deixa-ho buit per utilitzar el valor per defecte." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nahradí celý příkaz zmx včetně názvu relace. Ponechte prázdné pro použití výchozího." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilsidesætter hele zmx-kommandoen, inklusive sessionsnavnet. Lad feltet stå tomt for at bruge standarden." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Überschreibt den gesamten zmx-Befehl, einschließlich des Sitzungsnamens. Leer lassen, um den Standard zu verwenden." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sustituye todo el comando de zmx, incluido el nombre de la sesión. Déjalo vacío para usar el predeterminado." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Korvaa koko zmx-komennon, myös istunnon nimen. Jätä tyhjäksi käyttääksesi oletusta." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remplace toute la commande zmx, y compris le nom de session. Laissez vide pour utiliser la valeur par défaut." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עוקף את כל פקודת zmx, כולל שם ההפעלה. השאר ריק כדי להשתמש בברירת המחדל." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Felülírja a teljes zmx-parancsot, a munkamenet nevével együtt. Hagyja üresen az alapértelmezett használatához." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sostituisce l'intero comando zmx, incluso il nome della sessione. Lascia vuoto per usare il valore predefinito." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セッション名を含め、zmxコマンド全体を上書きします。空欄にするとデフォルトを使用します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션 이름을 포함한 전체 zmx 명령어를 대체합니다. 비워 두면 기본값을 사용합니다." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overstyrer hele zmx-kommandoen, inkludert øktnavnet. La feltet stå tomt for å bruke standarden." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overschrijft de volledige zmx-opdracht, inclusief de sessienaam. Laat leeg om de standaard te gebruiken." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zastępuje całe polecenie zmx, łącznie z nazwą sesji. Pozostaw puste, aby użyć domyślnego." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Substitui todo o comando do zmx, incluindo o nome da sessão. Deixe vazio para usar o padrão." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Substitui todo o comando do zmx, incluindo o nome da sessão. Deixe vazio para usar a predefinição." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Înlocuiește întreaga comandă zmx, inclusiv numele sesiunii. Lăsați gol pentru a folosi valoarea implicită." + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nadomesti celoten ukaz zmx, vključno z imenom seje. Pustite prazno za privzeto vrednost." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ersätter hela zmx-kommandot, inklusive sessionsnamnet. Lämna tomt för att använda standardvärdet." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замінює всю команду zmx, зокрема назву сеансу. Залиште порожнім, щоб використати типову." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ghi đè toàn bộ lệnh zmx, bao gồm cả tên phiên. Để trống để dùng giá trị mặc định." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "覆盖整条 zmx 命令,包括会话名称。留空则使用默认值。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "覆寫整道 zmx 指令,包括工作階段名稱。留空則使用預設值。" + } + } + } + }, + "The zmx command used when auto-start is enabled on a connection." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أمر zmx المستخدَم عند تفعيل البدء التلقائي في اتصال." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'ordre d'zmx que s'utilitza quan l'inici automàtic està activat en una connexió." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Příkaz zmx použitý, když je pro připojení zapnuté automatické spuštění." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den zmx-kommando, der bruges, når automatisk start er slået til for en forbindelse." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der zmx-Befehl, der verwendet wird, wenn Autostart für eine Verbindung aktiviert ist." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El comando de zmx que se usa cuando el inicio automático está activado en una conexión." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-komento, jota käytetään, kun automaattinen käynnistys on käytössä yhteydessä." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La commande zmx utilisée lorsque le démarrage automatique est activé sur une connexion." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פקודת zmx המשמשת כאשר הפעלה אוטומטית מופעלת בחיבור." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A zmx-parancs, amelyet az automatikus indítás használ egy kapcsolatnál." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il comando zmx usato quando l'avvio automatico è abilitato su una connessione." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "接続で自動起動が有効なときに使用されるzmxコマンドです。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결에서 자동 시작이 켜져 있을 때 사용되는 zmx 명령어입니다." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-kommandoen som brukes når automatisk start er slått på for en tilkobling." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De zmx-opdracht die wordt gebruikt wanneer automatisch starten is ingeschakeld voor een verbinding." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Polecenie zmx używane, gdy dla połączenia włączony jest automatyczny start." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O comando do zmx usado quando o início automático está ativado em uma conexão." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "O comando do zmx utilizado quando o arranque automático está ativado numa ligação." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comanda zmx folosită când pornirea automată este activată pentru o conexiune." + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukaz zmx, ki se uporabi, ko je za povezavo vklopljen samodejni zagon." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-kommandot som används när automatisk start är aktiverad för en anslutning." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Команда zmx, що використовується, коли для з'єднання ввімкнено автозапуск." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lệnh zmx được dùng khi bật khởi động tự động cho một kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在连接上启用自动启动时所使用的 zmx 命令。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在連線上啟用自動啟動時所使用的 zmx 指令。" + } + } + } + }, + "The zmx session name used by auto-start. Defaults to \"main\" if empty." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اسم جلسة zmx المستخدم بواسطة التشغيل التلقائي. القيمة الافتراضية \"main\" إذا كان فارغًا." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "El nom de la sessió zmx utilitzat per l'inici automàtic. Per defecte és \"main\" si està buit." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Název relace zmx používaný automatickým spuštěním. Výchozí hodnota je \"main\", pokud je prázdný." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tmux-sessionsnavnet brugt af autostart. Standard er \"main\", hvis tomt." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der von Autostart verwendete zmx-Sitzungsname. Standard ist \"main\", wenn leer." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El nombre de sesión zmx usado por el inicio automático. Por defecto es \"main\" si está vacío." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automaattisen käynnistyksen käyttämä zmx-istunnon nimi. Oletusarvo on \"main\", jos tyhjä." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le nom de session zmx utilisé par le démarrage automatique. Par défaut \"main\" si vide." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שם הפעלת zmx המשמש הפעלה אוטומטית. ברירת המחדל היא \"main\" אם ריק." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Az automatikus indítás által használt zmx-munkamenet neve. Alapértelmezés: \"main\", ha üres." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il nome della sessione zmx usato dall'avvio automatico. Predefinito: \"main\" se vuoto." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "自動起動で使用されるzmxセッション名。空の場合、デフォルトは「main」です。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "자동 시작에서 사용하는 zmx 세션 이름. 비어 있으면 기본값은 \"main\"입니다." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-øktnavnet brukt av autostart. Standard er \"main\" hvis tomt." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De zmx-sessienaam gebruikt door automatisch starten. Standaard \"main\" indien leeg." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nazwa sesji zmx używana przez automatyczne uruchamianie. Domyślnie \"main\", jeśli puste." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O nome da sessão zmx usado pelo início automático. O padrão é \"main\" se vazio." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "O nome da sessão zmx utilizado pelo arranque automático. Predefinição: \"main\" se vazio." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Numele sesiunii zmx utilizat de pornirea automată. Implicit \"main\" dacă este gol." + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ime seje zmx, ki ga uporablja samodejni zagon. Privzeto je \"main\", če je prazno." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tmux-sessionsnamnet som används av autostart. Standard är \"main\" om tomt." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назва сеансу zmx для автозапуску. За замовчуванням \"main\", якщо порожнє." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên phiên zmx được sử dụng bởi tự động khởi động. Mặc định là \"main\" nếu để trống." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动启动使用的 zmx 会话名称。为空时默认为「main」。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "自動啟動使用的 zmx 工作階段名稱。為空時預設為「main」。" + } + } + } + }, + "This command runs when zmx auto-start is enabled on a connection. zmx attaches to the session if it is running, or starts it." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتم تشغيل هذا الأمر عند تمكين التشغيل التلقائي لـ zmx على الاتصال. يرتبط zmx بالجلسة إذا كانت قيد التشغيل، أو يبدؤها." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aquesta ordre s'executa quan l'inici automàtic d'zmx està habilitat en una connexió. zmx es connecta a la sessió si s'està executant, o la inicia." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tento příkaz se spustí, když je na připojení zapnuto automatické spuštění zmx. zmx se připojí k relaci, pokud běží, nebo ji spustí." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denne kommando køres, når zmx-autostart er aktiveret på en forbindelse. zmx tilslutter sessionen, hvis den kører, eller starter den." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Befehl wird ausgeführt, wenn zmx-Autostart für eine Verbindung aktiviert ist. zmx hängt sich an die Sitzung an, falls sie läuft, oder startet sie." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este comando se ejecuta cuando el inicio automático de zmx está habilitado en una conexión. zmx se conecta a la sesión si está en ejecución, o la inicia." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tämä komento suoritetaan, kun zmx-automaattikäynnistys on käytössä yhteydellä. zmx liittyy istuntoon, jos se on käynnissä, tai käynnistää sen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cette commande s'exécute lorsque le démarrage automatique de zmx est activé sur une connexion. zmx se connecte à la session si elle est en cours d'exécution, sinon il la démarre." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פקודה זו מופעלת כאשר הפעלה אוטומטית של zmx מופעלת בחיבור. zmx מתחבר להפעלה אם היא פועלת, או מפעיל אותה." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ez a parancs fut, amikor a zmx automatikus indítás engedélyezve van egy kapcsolaton. A zmx csatlakozik a munkamenethez, ha az fut, vagy elindítja azt." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questo comando viene eseguito quando l'avvio automatico di zmx è abilitato su una connessione. zmx si collega alla sessione se è in esecuzione, altrimenti la avvia." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このコマンドは、接続でzmx自動起動が有効になっている場合に実行されます。zmxはセッションが実行中であればそのセッションに接続し、実行中でなければ起動します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 명령은 연결에서 zmx 자동 시작이 활성화된 경우 실행됩니다. zmx는 세션이 실행 중이면 연결하고, 실행 중이 아니면 시작합니다." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denne kommandoen kjøres når zmx-autostart er aktivert på en tilkobling. zmx kobler til økten hvis den kjører, eller starter den." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dit commando wordt uitgevoerd wanneer zmx automatisch starten is ingeschakeld op een verbinding. zmx verbindt met de sessie als deze actief is, of start deze." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "To polecenie jest uruchamiane, gdy automatyczne uruchamianie zmx jest włączone dla połączenia. zmx podłącza się do sesji, jeśli jest uruchomiona, lub ją uruchamia." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este comando é executado quando o início automático do zmx está habilitado em uma conexão. O zmx conecta à sessão se ela estiver em execução ou a inicia." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este comando é executado quando o arranque automático do zmx está ativado numa ligação. O zmx liga-se à sessão se esta estiver em execução ou inicia-a." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Această comandă rulează când pornirea automată zmx este activată pe o conexiune. zmx se atașează la sesiune dacă aceasta rulează sau o pornește." + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ta ukaz se izvede, ko je samodejni zagon zmx omogočen na povezavi. zmx se pripne na sejo, če ta teče, sicer jo zažene." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detta kommando körs när zmx-autostart är aktiverat på en anslutning. zmx ansluter till sessionen om den körs, annars startas den." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ця команда виконується, коли автозапуск zmx увімкнено для з'єднання. zmx приєднується до сеансу, якщо він запущений, або запускає його." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lệnh này chạy khi tự động khởi động zmx được bật trên kết nối. zmx sẽ kết nối vào phiên nếu phiên đang chạy, hoặc khởi động phiên đó." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当连接上启用 zmx 自动启动时,将运行此命令。如果会话正在运行,zmx 会附加到该会话,否则会启动它。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當連線上啟用 zmx 自動啟動時,將執行此指令。若工作階段正在執行,zmx 會附加到該工作階段,否則會啟動它。" + } + } + } + }, + "zmx" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx" + } + } + } + }, + "zmx Auto-Start" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "البدء التلقائي لـ zmx" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inici automàtic d'zmx" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatické spuštění zmx" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk start af zmx" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-Autostart" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicio automático de zmx" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmxin automaattinen käynnistys" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Démarrage automatique de zmx" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הפעלה אוטומטית של zmx" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx automatikus indítás" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvio automatico di zmx" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx自動起動" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 자동 시작" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk start av zmx" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx automatisch starten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatyczny start zmx" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Início Automático do zmx" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arranque Automático do zmx" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pornire automată zmx" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samodejni zagon zmx" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk start av zmx" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автозапуск zmx" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khởi động tự động zmx" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 自动启动" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 自動啟動" + } + } + } + }, + "zmx attaches automatically" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يرتبط zmx تلقائيًا" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx es connecta automàticament" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx se připojuje automaticky" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx tilslutter automatisk" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx hängt sich automatisch an" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx se conecta automáticamente" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx liittyy automaattisesti" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx s'attache automatiquement" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx מתחבר אוטומטית" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A zmx automatikusan csatlakozik" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx si collega automaticamente" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx自動接続" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 자동 연결" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx kobler til automatisk" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx verbindt automatisch" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx podłącza się automatycznie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx conecta automaticamente" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx liga-se automaticamente" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx se atașează automat" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx se pripne samodejno" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx ansluter automatiskt" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx приєднується автоматично" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx tự động kết nối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 自动附加" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 自動附加" + } + } + } + }, + "zmx enabled, launch command configured" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx مُفعَّل، تم تكوين أمر التشغيل" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx activat, ordre d'inici configurada" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx povolen, spouštěcí příkaz nakonfigurován" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx aktiveret, startkommando konfigureret" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx aktiviert, Startbefehl konfiguriert" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx activado, comando de inicio configurado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx käytössä, käynnistyskomento määritetty" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx activé, commande de lancement configurée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx מופעל, פקודת הפעלה מוגדרת" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx engedélyezve, indítási parancs konfigurálva" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx attivato, comando di avvio configurato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx有効、起動コマンド設定済み" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 활성화됨, 실행 명령 설정됨" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx aktivert, startkommando konfigurert" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx ingeschakeld, startcommando geconfigureerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx włączony, polecenie uruchomieniowe skonfigurowane" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx ativado, comando de inicialização configurado" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx ativado, comando de lançamento configurado" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx activat, comandă de lansare configurată" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx omogočen, zagonski ukaz nastavljen" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx aktiverat, startkommando konfigurerat" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx увімкнено, команду запуску налаштовано" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã bật zmx, đã cấu hình lệnh khởi chạy" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 已启用,启动命令已配置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 已啟用,啟動命令已設定" + } + } + } + }, + "zmx reads a name starting with '-' as an option. This name falls back to the global default." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يقرأ zmx الاسم الذي يبدأ بـ '-' كخيار. سيعود هذا الاسم إلى الإعداد الافتراضي العام." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx interpreta un nom que comença amb '-' com una opció. Aquest nom recorre al valor global per defecte." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx čte název začínající znakem '-' jako volbu. Tento název se vrátí ke globální výchozí hodnotě." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx læser et navn, der begynder med '-', som en indstilling. Dette navn falder tilbage til den globale standard." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx liest einen Namen, der mit '-' beginnt, als Option. Dieser Name fällt auf die globale Vorgabe zurück." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx interpreta un nombre que empieza por '-' como una opción. Este nombre recurre al valor global predeterminado." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx tulkitsee '-'-merkillä alkavan nimen valitsimeksi. Tämä nimi palautuu yleiseen oletusarvoon." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx interprète un nom commençant par '-' comme une option. Ce nom revient à la valeur globale par défaut." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "‏zmx קורא שם שמתחיל ב-'-' כאפשרות. שם זה חוזר לברירת המחדל הגלובלית." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A zmx a '-' karakterrel kezdődő nevet kapcsolóként értelmezi. Ez a név a globális alapértelmezésre vált vissza." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx interpreta un nome che inizia con '-' come un'opzione. Questo nome torna al valore predefinito globale." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx は '-' で始まる名前をオプションとして解釈します。この名前はグローバルのデフォルトに戻ります。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx는 '-'로 시작하는 이름을 옵션으로 인식합니다. 이 이름은 전역 기본값으로 대체됩니다." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx leser et navn som starter med '-', som et valg. Dette navnet faller tilbake til den globale standarden." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx leest een naam die met '-' begint als een optie. Deze naam valt terug op de algemene standaard." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx odczytuje nazwę zaczynającą się od '-' jako opcję. Ta nazwa powróci do domyślnej wartości globalnej." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O zmx interpreta um nome iniciado por '-' como uma opção. Esse nome retorna ao padrão global." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "O zmx interpreta um nome iniciado por '-' como uma opção. Este nome regressa à predefinição global." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx interpretează un nume care începe cu '-' ca opțiune. Acest nume revine la valoarea implicită globală." + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx ime, ki se začne z '-', prebere kot možnost. To ime se vrne na globalno privzeto vrednost." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx tolkar ett namn som börjar med '-' som en flagga. Det här namnet återgår till den globala standarden." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx читає ім'я, що починається з '-', як параметр. Це ім'я повертається до глобального значення за замовчуванням." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx đọc tên bắt đầu bằng '-' như một tùy chọn. Tên này sẽ quay lại giá trị mặc định chung." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 会将以“-”开头的名称视为选项。此名称将回退到全局默认值。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx 會將以「-」開頭的名稱視為選項。此名稱將回復為全域預設值。" + } + } + } + }, "—" : { "localizations" : { "ar" : { @@ -72554,6 +75066,161 @@ } } }, + "Auto-start zmx on the remote host" : { + "comment" : "Mosh help: --zmx option\nSSH help: --zmx option\nTrzsz help: --zmx option", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "بدء zmx تلقائيًا على المضيف البعيد" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciar zmx automàticament a l'amfitrió remot" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automaticky spustit zmx na vzdáleném hostiteli" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start zmx automatisk på fjernværten" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx automatisch auf dem Remote-Host starten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciar zmx automáticamente en el host remoto" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Käynnistä zmx automaattisesti etäisännässä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Démarrer zmx automatiquement sur l'hôte distant" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הפעלה אוטומטית של zmx במארח המרוחק" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A zmx automatikus indítása a távoli gazdagépen" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvia automaticamente zmx sull'host remoto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リモートホストで zmx を自動起動" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원격 호스트에서 zmx 자동 시작" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start zmx automatisk på fjernverten" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx automatisch starten op de externe host" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatycznie uruchom zmx na zdalnym hoście" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciar o zmx automaticamente no host remoto" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciar o zmx automaticamente no anfitrião remoto" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pornește automat zmx pe gazda la distanță" + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samodejno zaženi zmx na oddaljenem gostitelju" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starta zmx automatiskt på fjärrvärden" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматично запускати zmx на віддаленому хості" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tự động khởi động zmx trên máy chủ từ xa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在远程主机上自动启动 zmx" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在遠端主機上自動啟動 zmx" + } + } + } + }, "Automatic" : { "comment" : "Appearance mode: follow system setting\nHole-punch mode: automatic detection\nVNC security preference", "localizations" : { @@ -518530,6 +521197,160 @@ } } }, + "The zmx session this profile attaches to or creates on connect. Leave on the global default to use the session name from Settings. Names may use letters, numbers, '.', '_' and '-', and may not start with '-'." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جلسة zmx التي يتصل بها هذا الملف الشخصي أو ينشئها عند الاتصال. اتركه على الافتراضي العام لاستخدام اسم الجلسة من الإعدادات. يمكن أن تحتوي الأسماء على أحرف وأرقام و'.' و'_' و'-'، ولا يمكن أن تبدأ بـ '-'." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "La sessió zmx a la qual aquest perfil es connecta o que crea en connectar-se. Deixeu-ho al valor global per defecte per utilitzar el nom de sessió de la configuració. Els noms poden fer servir lletres, xifres, '.', '_' i '-', i no poden començar amb '-'." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relace zmx, ke které se tento profil při připojení připojí nebo kterou vytvoří. Ponechte globální výchozí hodnotu, chcete-li použít název relace z Nastavení. Názvy mohou obsahovat písmena, číslice, '.', '_' a '-' a nesmí začínat znakem '-'." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den zmx-session, som denne profil tilsluttes eller opretter ved forbindelse. Behold den globale standard for at bruge sessionsnavnet fra Indstillinger. Navne må bruge bogstaver, tal, '.', '_' og '-' og må ikke begynde med '-'." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die zmx-Sitzung, mit der sich dieses Profil beim Verbinden verbindet oder die es erstellt. Belasse die globale Vorgabe, um den Sitzungsnamen aus den Einstellungen zu verwenden. Namen dürfen Buchstaben, Ziffern, '.', '_' und '-' enthalten und nicht mit '-' beginnen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La sesión de zmx a la que este perfil se conecta o que crea al conectar. Déjalo en el valor global predeterminado para usar el nombre de sesión de Ajustes. Los nombres pueden usar letras, números, '.', '_' y '-', y no pueden empezar por '-'." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-istunto, johon tämä profiili yhdistää tai jonka se luo yhdistettäessä. Jätä yleiseen oletusarvoon käyttääksesi asetusten istuntonimeä. Nimissä voi käyttää kirjaimia, numeroita sekä merkkejä '.', '_' ja '-', eivätkä ne saa alkaa merkillä '-'." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La session zmx à laquelle ce profil se connecte ou qu'il crée à la connexion. Laissez la valeur globale par défaut pour utiliser le nom de session des Réglages. Les noms peuvent contenir des lettres, des chiffres, '.', '_' et '-', et ne peuvent pas commencer par '-'." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "‏הפעלת zmx שאליה פרופיל זה מתחבר או שהוא יוצר בעת ההתחברות. השאר את ברירת המחדל הגלובלית כדי להשתמש בשם ההפעלה מההגדרות. שמות יכולים לכלול אותיות, ספרות, '.', '_' ו-'-', ואינם יכולים להתחיל ב-'-'." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A zmx-munkamenet, amelyhez ez a profil csatlakozik, vagy amelyet létrehoz csatlakozáskor. Hagyd a globális alapértelmezésen a Beállítások munkamenetnevének használatához. A nevek betűket, számjegyeket, '.', '_' és '-' karaktereket tartalmazhatnak, és nem kezdődhetnek '-' karakterrel." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La sessione zmx a cui questo profilo si connette o che crea alla connessione. Lascia il valore predefinito globale per usare il nome sessione dalle Impostazioni. I nomi possono usare lettere, numeri, '.', '_' e '-', e non possono iniziare con '-'." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このプロファイルが接続時に接続または作成する zmx セッションです。設定のセッション名を使うにはグローバルのデフォルトのままにします。名前には英数字と '.'、'_'、'-' を使用でき、'-' で始めることはできません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 프로파일이 연결 시 연결하거나 생성하는 zmx 세션입니다. 설정의 세션 이름을 사용하려면 전역 기본값으로 두십시오. 이름에는 문자, 숫자, '.', '_', '-'를 사용할 수 있으며 '-'로 시작할 수 없습니다." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-økten denne profilen kobler til eller oppretter ved tilkobling. La den stå på den globale standarden for å bruke øktnavnet fra Innstillinger. Navn kan bruke bokstaver, tall, '.', '_' og '-', og kan ikke starte med '-'." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De zmx-sessie waarmee dit profiel verbinding maakt of die het aanmaakt bij verbinden. Laat op de algemene standaard staan om de sessienaam uit Instellingen te gebruiken. Namen mogen letters, cijfers, '.', '_' en '-' bevatten en mogen niet met '-' beginnen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sesja zmx, z którą ten profil się łączy lub którą tworzy przy połączeniu. Pozostaw wartość domyślną globalną, aby użyć nazwy sesji z Ustawień. Nazwy mogą zawierać litery, cyfry, '.', '_' i '-' oraz nie mogą zaczynać się od '-'." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A sessão zmx à qual este perfil se conecta ou que cria ao conectar. Deixe no padrão global para usar o nome de sessão dos Ajustes. Os nomes podem usar letras, números, '.', '_' e '-', e não podem começar com '-'." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "A sessão zmx à qual este perfil se liga ou que cria ao ligar. Deixe na predefinição global para usar o nome de sessão das Definições. Os nomes podem usar letras, números, '.', '_' e '-', e não podem começar por '-'." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sesiunea zmx la care se conectează acest profil sau pe care o creează la conectare. Lasă valoarea implicită globală pentru a folosi numele sesiunii din Configurări. Numele pot folosi litere, cifre, '.', '_' și '-' și nu pot începe cu '-'." + } + }, + "sl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seja zmx, s katero se ta profil poveže ali jo ustvari ob povezavi. Pustite globalno privzeto vrednost, da uporabite ime seje iz Nastavitev. Imena lahko vsebujejo črke, števke, '.', '_' in '-' ter se ne smejo začeti z '-'." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "zmx-sessionen som den här profilen ansluter till eller skapar vid anslutning. Låt den stå på den globala standarden för att använda sessionsnamnet från Inställningar. Namn kan använda bokstäver, siffror, '.', '_' och '-', och får inte börja med '-'." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сеанс zmx, до якого цей профіль підключається або який створює під час підключення. Залиште глобальне значення за замовчуванням, щоб використати назву сеансу з Параметрів. Назви можуть містити літери, цифри, '.', '_' і '-' та не можуть починатися з '-'." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phiên zmx mà hồ sơ này kết nối tới hoặc tạo khi kết nối. Giữ nguyên mặc định chung để dùng tên phiên từ Cài đặt. Tên có thể dùng chữ cái, chữ số, '.', '_' và '-', và không được bắt đầu bằng '-'." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此配置在连接时接入或创建的 zmx 会话。保持全局默认值可使用“设置”中的会话名称。名称可使用字母、数字、“.”、“_”和“-”,且不能以“-”开头。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此描述檔在連線時接入或建立的 zmx 工作階段。保持全域預設值可使用「設定」中的工作階段名稱。名稱可使用字母、數字、「.」、「_」和「-」,且不能以「-」開頭。" + } + } + } + }, "The VNC stream itself is unencrypted, but every byte travels inside the SSH tunnel." : { "localizations" : { "ar" : { @@ -533967,160 +536788,6 @@ } } }, - "tmux Tips" : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "نصائح tmux" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Consells de tmux" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tipy pro tmux" - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux-tip" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux-Tipps" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Consejos de tmux" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux-vinkit" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Conseils tmux" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "טיפים ל-tmux" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux tippek" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Consigli su tmux" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmuxのヒント" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux 팁" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux-tips" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux-tips" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wskazówki tmux" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dicas do tmux" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sugestões do tmux" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sfaturi tmux" - } - }, - "sl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nasveti za tmux" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux-tips" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Поради щодо tmux" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mẹo tmux" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux 提示" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "tmux 提示" - } - } - } - }, "tmux window" : { "localizations" : { "ar" : { diff --git a/README.md b/README.md index d9119654..93353150 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,8 @@ brew install --cask rootshell - **Multipath TCP** - MPTCP over Apple Network.framework maintains subflows on WiFi and cellular simultaneously for near-instant handover (requires Linux 5.6+ on server) - **Native SCP & SFTP** - Built-in `scp` and interactive `sftp` client with tab completion, glob patterns, and real-time progress - **Background SSH Tunnels** - Maintain port forwards without a terminal UI with auto-start on launch and byte transfer statistics -- **Auto-start tmux** - Automatically attach to or create tmux sessions on connect -- **tmux Session Discovery** - After connecting, lists active tmux sessions with window count and live terminal preview +- **Auto-start Multiplexer** - Automatically attach to or create a tmux, herdr or zmx session on connect +- **Multiplexer Session Discovery** - After connecting, lists active tmux, zellij, herdr and zmx sessions with per-multiplexer detail and a live terminal preview - **Tailscale Integration** - Device discovery and SSH to your tailnet with no-auth support - **Host Shorthand (HSS)** - Pattern-based hostname expansion with YAML configuration - **Connection Health** - Real-time RTT and packet loss tracking with time series chart and negotiated cryptographic algorithm details diff --git a/rootshell-helper/Sources/EnvironmentBuilder.swift b/rootshell-helper/Sources/EnvironmentBuilder.swift index d7aea944..81ce3aa8 100644 --- a/rootshell-helper/Sources/EnvironmentBuilder.swift +++ b/rootshell-helper/Sources/EnvironmentBuilder.swift @@ -58,6 +58,16 @@ class EnvironmentBuilder { LocaleHelper.posixLocale } + /// The launchd-provided per-user temp directory, or nil on failure. + private static func darwinUserTempDir() -> String? { + let size = confstr(_CS_DARWIN_USER_TEMP_DIR, nil, 0) + guard size > 0 else { return nil } + var buffer = [Int8](repeating: 0, count: size) + let result = confstr(_CS_DARWIN_USER_TEMP_DIR, &buffer, size) + guard result > 0, result <= size else { return nil } + return String(cString: buffer) + } + /// Convenience initializer that auto-detects bundle paths convenience init(bundle: Bundle = .main, version: String = "1.0.0") { var config = Config() @@ -117,6 +127,11 @@ class EnvironmentBuilder { // Minimal default PATH - login shell will extend this env["PATH"] = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + // Multiplexer socket paths rely on launchd's per-user temp directory. + if let tmpDir = Self.darwinUserTempDir() { + env["TMPDIR"] = tmpDir + } + // TERM: an explicit value from the app's settings wins outright — the // user picked it, and honoring it is the whole point of the setting. if let termType = config.termType, !termType.isEmpty { diff --git a/rootshell-helper/Sources/ProcessExecutor.m b/rootshell-helper/Sources/ProcessExecutor.m index b90c5d58..216e8066 100644 --- a/rootshell-helper/Sources/ProcessExecutor.m +++ b/rootshell-helper/Sources/ProcessExecutor.m @@ -324,6 +324,25 @@ + (NSString *)defaultShell { return @"/bin/sh"; } +/// The launchd-provided per-user temp directory, or nil on failure. ++ (nullable NSString *)darwinUserTempDir { + size_t size = confstr(_CS_DARWIN_USER_TEMP_DIR, NULL, 0); + if (size == 0) { + return nil; + } + char *buffer = malloc(size); + if (!buffer) { + return nil; + } + size_t result = confstr(_CS_DARWIN_USER_TEMP_DIR, buffer, size); + NSString *value = nil; + if (result > 0 && result <= size) { + value = [NSString stringWithUTF8String:buffer]; + } + free(buffer); + return value; +} + /// Build environment array with minimal required variables plus custom ones + (char **)buildEnvironmentWithCustom:(nullable NSDictionary *)customEnv { NSMutableDictionary *env = [NSMutableDictionary dictionary]; @@ -346,6 +365,12 @@ + (char **)buildEnvironmentWithCustom:(nullable NSDictionary`. Ghostty hands the +/// sequence's payload through `GHOSTTY_ACTION_PWD` as it arrived, so a directory +/// with a space in it would otherwise remain percent-encoded. +/// +/// A value that is not a `file://` URI is returned untouched. That case is not +/// an error: a bare path may legitimately contain spaces or a literal `%`, and +/// running one through a percent-decoder corrupts it. +nonisolated enum WorkingDirectoryURI { + static func path(_ value: String) -> String { + guard value.hasPrefix("file://") else { return value } + guard let components = URLComponents(string: value), !components.path.isEmpty else { + return value + } + return components.path + } +} diff --git a/rootshell/Features/LocalShell/Commands/LocalShellSession+Help.swift b/rootshell/Features/LocalShell/Commands/LocalShellSession+Help.swift index 053f0c37..fabc4d25 100644 --- a/rootshell/Features/LocalShell/Commands/LocalShellSession+Help.swift +++ b/rootshell/Features/LocalShell/Commands/LocalShellSession+Help.swift @@ -207,7 +207,7 @@ Use this after editing the file from a local shell or through a symlink in your let examplesHeader = String(localized: "Examples:", comment: "Command help: examples section header") let helpText = """ -usage: ssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--path] [-o option] destination [command] +usage: ssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--path] [-o option] destination [command] \(optionsHeader) -p port \(String(localized: "Connect to this port (default: 22)", comment: "SSH help: -p option")) @@ -218,6 +218,7 @@ usage: ssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--he -AA \(String(localized: "Enable SSH agent forwarding with auto-approval", comment: "SSH help: -AA option")) --tmux \(String(localized: "Auto-start tmux on the remote host", comment: "SSH help: --tmux option")) --herdr \(String(localized: "Auto-start herdr on the remote host", comment: "SSH help: --herdr option")) + --zmx \(String(localized: "Auto-start zmx on the remote host", comment: "SSH help: --zmx option")) --path \(String(localized: "Prepend Rootshell's PATH wrapper for remote exec commands", comment: "SSH help: --path option")) -o option \(String(localized: "Set SSH option (Port, User, ProxyJump, IdentityFile)", comment: "SSH help: -o option")) @@ -250,6 +251,7 @@ usage: ssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--he ssh -AA user@host ssh --tmux user@host ssh --herdr user@host + ssh --zmx user@host ssh --path user@host wish serve ssh user@host ls -la /tmp ssh user@host "echo hello" @@ -399,8 +401,8 @@ usage: sftp [-P port] [-i identity] [-J jump_host] [-o option] [user@]host let examplesHeader = String(localized: "Examples:", comment: "Command help: examples section header") let helpText = """ -usage: roam [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--predict mode] [--predict-overwrite] destination - mosh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--predict mode] [--predict-overwrite] destination +usage: roam [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--predict mode] [--predict-overwrite] destination + mosh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--predict mode] [--predict-overwrite] destination \(optionsHeader) -p port \(String(localized: "Connect to this port (default: 22)", comment: "Mosh help: -p option")) @@ -411,6 +413,7 @@ usage: roam [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--h -AA \(String(localized: "Enable SSH agent forwarding with auto-approval", comment: "Mosh help: -AA option")) --tmux \(String(localized: "Auto-start tmux on the remote host", comment: "Mosh help: --tmux option")) --herdr \(String(localized: "Auto-start herdr on the remote host", comment: "Mosh help: --herdr option")) + --zmx \(String(localized: "Auto-start zmx on the remote host", comment: "Mosh help: --zmx option")) --predict mode \(String(localized: "Prediction mode: always, adaptive, never (default: always)", comment: "Mosh help: --predict option")) --predict-overwrite \(String(localized: "Replace existing cells when predicting input", comment: "Mosh help: --predict-overwrite option")) --no-predict-overwrite \(String(localized: "Use inserting predictions for this session", comment: "Mosh help: --no-predict-overwrite option")) @@ -442,6 +445,7 @@ usage: roam [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--h roam -J bastion user@internal mosh --tmux user@host mosh --herdr user@host + mosh --zmx user@host """ Task { @MainActor [weak self] in @@ -544,8 +548,8 @@ usage: ssh-copy-id [-fnp] [-i identity] [-t target] [-o option] [user@]hostname let examplesHeader = String(localized: "Examples:", comment: "Command help: examples section header") let helpText = """ -usage: tssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--quic|--kcp] destination - trzsz [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr] [--quic|--kcp] destination +usage: tssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--quic|--kcp] destination + trzsz [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--herdr|--zmx] [--quic|--kcp] destination \(optionsHeader) -p port \(String(localized: "Connect to this port (default: 22)", comment: "Trzsz help: -p option")) @@ -556,6 +560,7 @@ usage: tssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--h -AA \(String(localized: "Enable SSH agent forwarding with auto-approval", comment: "Trzsz help: -AA option")) --tmux \(String(localized: "Auto-start tmux on the remote host", comment: "Trzsz help: --tmux option")) --herdr \(String(localized: "Auto-start herdr on the remote host", comment: "Trzsz help: --herdr option")) + --zmx \(String(localized: "Auto-start zmx on the remote host", comment: "Trzsz help: --zmx option")) --quic \(String(localized: "Force QUIC transport mode", comment: "Trzsz help: --quic option")) --kcp \(String(localized: "Force KCP transport mode", comment: "Trzsz help: --kcp option")) --server path \(String(localized: "Path to tssh server on remote", comment: "Trzsz help: --server option")) @@ -586,6 +591,7 @@ usage: tssh [-p port] [-l user] [-i identity] [-J jumphost] [-A|-AA] [--tmux|--h trzsz -J bastion user@internal tssh --tmux user@host tssh --herdr user@host + tssh --zmx user@host """ Task { @MainActor [weak self] in diff --git a/rootshell/Features/LocalShell/LocalShellSession+EmbeddedSessions.swift b/rootshell/Features/LocalShell/LocalShellSession+EmbeddedSessions.swift index 5dc23005..59f8dab4 100644 --- a/rootshell/Features/LocalShell/LocalShellSession+EmbeddedSessions.swift +++ b/rootshell/Features/LocalShell/LocalShellSession+EmbeddedSessions.swift @@ -95,6 +95,7 @@ extension LocalShellSession { tmuxAutoEnable: config.tmuxAutoEnable, tmuxAutoMode: config.tmuxAutoMode, herdrAutoEnable: config.herdrAutoEnable, + zmxAutoEnable: config.zmxAutoEnable, remoteCommand: config.remoteCommand, remoteCommandPolicy: config.remoteCommandPolicy, multiplexerSessionName: config.multiplexerSessionName @@ -279,6 +280,7 @@ extension LocalShellSession { tmuxAutoEnable: config.tmuxAutoEnable, tmuxAutoMode: config.tmuxAutoMode, herdrAutoEnable: config.herdrAutoEnable, + zmxAutoEnable: config.zmxAutoEnable, launchCommand: config.launchCommand, launchCommandMode: config.launchCommandMode, terminalType: config.terminalType, diff --git a/rootshell/Features/Multiplexer/Expose/HerdrExposeAdapter.swift b/rootshell/Features/Multiplexer/Expose/HerdrExposeAdapter.swift index 86d1cbce..80eac158 100644 --- a/rootshell/Features/Multiplexer/Expose/HerdrExposeAdapter.swift +++ b/rootshell/Features/Multiplexer/Expose/HerdrExposeAdapter.swift @@ -33,7 +33,7 @@ nonisolated struct HerdrExposeAdapter: MultiplexerExposeAdapter { return MuxScript.wrap(body, nonce: nonce) } - func parseTick(output: String, nonce: String) -> MuxTickResult? { + func parseTick(output: String, session _: String?, nonce: String) -> MuxTickResult? { let sections = MuxScript.sections(of: output, nonce: nonce) guard sections.found, !sections.unsupported else { return nil } guard let root = MuxScript.json(sections.topology) as? [String: Any] else { return nil } diff --git a/rootshell/Features/Multiplexer/Expose/MultiplexerExposeAdapter.swift b/rootshell/Features/Multiplexer/Expose/MultiplexerExposeAdapter.swift index c46dd10b..f5758027 100644 --- a/rootshell/Features/Multiplexer/Expose/MultiplexerExposeAdapter.swift +++ b/rootshell/Features/Multiplexer/Expose/MultiplexerExposeAdapter.swift @@ -2,12 +2,6 @@ // MultiplexerExposeAdapter.swift // rootshell // -// One adapter per raw multiplexer turns "what do I need this tick" into a -// single shell script and its output back into a MuxTickResult. Scripts run -// through RemoteExecProbe on the pane's own connection, so they must be -// self-contained, nonce-framed, and exit 0 (Citadel discards the output of -// a failing command). -// import Foundation @@ -19,6 +13,60 @@ nonisolated struct MuxTickRequest: Sendable { var knownRevisions: [String: String] = [:] } +/// Checks whether screen state is compatible with a multiplexer attachment. +nonisolated enum MuxScreenGate { + /// Passthrough multiplexers are admitted on either screen. + static func admits(ownsAlternateScreen: Bool, alternateScreenActive: Bool) -> Bool { + !ownsAlternateScreen || alternateScreenActive + } +} + +/// Checks whether a detachable multiplexer has a shell underneath it. +nonisolated enum MuxDetachGate { + static func hasFallbackShell( + hasRemoteCommand: Bool, + hasInitialCommandLaunch: Bool, + tmuxAutoEnable: Bool, + herdrAutoEnable: Bool, + zmxAutoEnable: Bool + ) -> Bool { + !(hasRemoteCommand || hasInitialCommandLaunch || tmuxAutoEnable || herdrAutoEnable || zmxAutoEnable) + } +} + +/// Classifies a detach-and-reattach attempt from its client-count census. +nonisolated enum MuxZmxDetachTransfer { + enum Result: Equatable { + case confirmed + case unchanged + case detachedOnly + case ambiguous + } + + static func classify( + sourceBefore: Int, + targetBefore: Int, + sourceAfter: Int, + targetAfter: Int + ) -> Result { + if sourceAfter == sourceBefore - 1, + targetAfter == targetBefore + 1 { + return .confirmed + } + if sourceAfter == sourceBefore, + targetAfter == targetBefore { + return .unchanged + } + // A single-client source proves this pane reached its fallback shell. + if sourceBefore == 1, + sourceAfter == 0, + targetAfter == targetBefore { + return .detachedOnly + } + return .ambiguous + } +} + nonisolated protocol MultiplexerExposeAdapter: Sendable { var type: MultiplexerType { get } @@ -28,10 +76,25 @@ nonisolated protocol MultiplexerExposeAdapter: Sendable { func tickScript(session: String?, request: MuxTickRequest, nonce: String) -> String - /// nil when the multiplexer is unusable here (too old, no session). - func parseTick(output: String, nonce: String) -> MuxTickResult? + /// nil when the multiplexer is unavailable or has no usable session. + func parseTick(output: String, session: String?, nonce: String) -> MuxTickResult? + + /// Whether switching from `session` to `tabID` is safe. + func canFocus(session: String?, tabID: String) -> Bool func focusScript(session: String?, tabID: String) -> String + + /// Whether the focus command's output confirms the switch. + func parseFocusResult(output: String, session: String?, tabID: String) -> Bool + + /// Floor for the feed's tick pacing. + var minInterval: TimeInterval { get } +} + +nonisolated extension MultiplexerExposeAdapter { + var minInterval: TimeInterval { 0 } + func parseFocusResult(output: String, session: String?, tabID: String) -> Bool { true } + func canFocus(session: String?, tabID: String) -> Bool { true } } /// Marker framing and shell quoting shared by the adapters. diff --git a/rootshell/Features/Multiplexer/Expose/MultiplexerExposeFeed.swift b/rootshell/Features/Multiplexer/Expose/MultiplexerExposeFeed.swift index dec2f1b0..1239c7a2 100644 --- a/rootshell/Features/Multiplexer/Expose/MultiplexerExposeFeed.swift +++ b/rootshell/Features/Multiplexer/Expose/MultiplexerExposeFeed.swift @@ -2,12 +2,6 @@ // MultiplexerExposeFeed.swift // rootshell // -// Keeps a raw multiplexer session's tabs and pane frames fresh while the -// exposé is up, over one-shot exec commands on the pane's own connection -// (RemoteExecProbe). Ticks are strictly serialized and adaptively paced: -// fast while frames change, backing off when the session is quiet, with -// visible panes fetched first and the rest on a slower cadence. -// import Foundation import GhosttyKit @@ -18,14 +12,10 @@ import os final class MultiplexerExposeFeed { enum State: Equatable { case idle - /// No binding yet: working out which multiplexer a local pane is - /// attached to (hand-started `tmux` / `herdr` / `zellij`). case detecting case loading case live - /// The multiplexer is unusable here (too old, no session): show app tabs. case unsupported - /// Ticks keep failing; the last frames stay up with a hint. case failed } @@ -35,7 +25,6 @@ final class MultiplexerExposeFeed { private(set) var snapshot: MuxExposeSnapshot? private(set) var type: MultiplexerType? private(set) var sessionName: String? - /// Fired on state or topology changes (frames are polled by the views). var onChange: (() -> Void)? private(set) weak var terminal: Ghostty.TerminalView? @@ -44,19 +33,25 @@ final class MultiplexerExposeFeed { private var adapter: (any MultiplexerExposeAdapter)? private var frames: [String: MuxPaneFrame] = [:] private var loop: Task? + private var focusTask: Task? private var sleeper: Task? private var stopTask: Task? private var visiblePanes: Set = [] private var lastFetchAt: [String: CFTimeInterval] = [:] private var hints: [String: String] = [:] - /// Bumped by every teardown; identifies the run that owns the feed. + /// Negative detection is cached across short exposé lifetimes. + private var negativeDetectAt: [ObjectIdentifier: CFTimeInterval] = [:] private var generation: UInt64 = 0 + private var focusGeneration: UInt64 = 0 + /// Distinguishes authoritative absence from probe failures. + private var detectionWasConclusive = false + /// Cached zmx bindings are revalidated before serving their cached page. + private var validatingZmxBinding = false private var tickCount = 0 private var interval: TimeInterval = baseInterval private var failures = 0 private var fetchCap = Int.max private var cleanTicks = 0 - /// Last presentation, reusable for an instant repaint on quick re-entry. private var cache: (owner: ObjectIdentifier, session: String, snapshot: MuxExposeSnapshot, frames: [String: MuxPaneFrame], at: CFTimeInterval)? @@ -68,9 +63,9 @@ final class MultiplexerExposeFeed { private static let maxPreviewPanesPerTab = 6 private static let responseCap = 512 * 1024 private static let tickTimeout: TimeInterval = 5 - /// Retries while another probe holds the transport's single slot. + private static let negativeProbeCooldown: CFTimeInterval = 4 private static let focusAttempts = 12 - /// Consecutive failures before the tray says the session is unavailable. + private static let focusMaxResponseBytes = 32 * 1024 private static let failuresBeforeUnavailable = 3 // MARK: - Identity @@ -93,13 +88,13 @@ final class MultiplexerExposeFeed { var isServing: Bool { state == .loading || state == .live || state == .failed } - /// Short name for the tray header. var title: String { let mux: String switch type { case .tmux: mux = "tmux" case .zellij: mux = "zellij" case .herdr: mux = "herdr" + case .zmx: mux = "zmx" case nil: mux = "" } if let sessionName, !sessionName.isEmpty { return "\(mux) · \(sessionName)" } @@ -108,21 +103,32 @@ final class MultiplexerExposeFeed { // MARK: - Eligibility - /// The multiplexer this terminal is attached to, once it owns the screen. - /// `hasOwnedAltScreen` is maintained by agent attention's scans, which - /// may not have run yet; the surface's own alt-screen state stands in. + /// Returns the active multiplexer binding, preferring the raw slot. static func binding(for terminal: Ghostty.TerminalView?) -> Ghostty.TerminalView.RawMultiplexerBinding? { - guard let terminal, let binding = terminal.rawMultiplexer, RemoteExecProbe.canProbe(terminal), - binding.hasOwnedAltScreen || isAlternateScreenActive(terminal) else { return nil } - return binding + guard let terminal, RemoteExecProbe.canProbe(terminal) else { return nil } + if let binding = terminal.rawMultiplexer, adapter(for: binding.type) != nil, + binding.hasOwnedAltScreen || isAlternateScreenActive(terminal) { + return binding + } + if let binding = terminal.passthroughMultiplexer, adapter(for: binding.type) != nil { + return binding + } + return nil } - /// A pane with no binding whose screen is taken by something: ask the - /// host what is running there. The alternate screen is the gate — every - /// multiplexer holds it for its whole attach — so an ordinary shell is - /// never probed just because the exposé opened. + /// Returns the adapter for a supported multiplexer type. + static func adapter(for type: MultiplexerType) -> (any MultiplexerExposeAdapter)? { + switch type { + case .herdr: HerdrExposeAdapter() + case .tmux: TmuxExposeAdapter() + case .zellij: ZellijExposeAdapter() + case .zmx: ZmxExposeAdapter() + } + } + + /// A pane can be probed even when no binding has been recorded yet. static func canDetect(_ terminal: Ghostty.TerminalView) -> Bool { - RemoteExecProbe.canProbe(terminal) && isAlternateScreenActive(terminal) + RemoteExecProbe.canProbe(terminal) } private static func isAlternateScreenActive(_ terminal: Ghostty.TerminalView) -> Bool { @@ -132,7 +138,6 @@ final class MultiplexerExposeFeed { return altActive } - /// `ttys004` for a local macOS pane; nil for anything remote. private static func localTTY(_ terminal: Ghostty.TerminalView) -> String? { #if targetEnvironment(macCatalyst) guard terminal.connectionConfig.underlyingSSHConfig == nil, @@ -151,25 +156,41 @@ final class MultiplexerExposeFeed { func start(terminal: Ghostty.TerminalView) -> Bool { let binding = Self.binding(for: terminal) guard binding != nil || Self.canDetect(terminal) else { - Self.logger.debug("not a multiplexer pane: bound=\(terminal.rawMultiplexer != nil) probe=\(RemoteExecProbe.canProbe(terminal)) alt=\(Self.isAlternateScreenActive(terminal))") + Self.logger.debug("not a multiplexer pane: bound=\(terminal.rawMultiplexer != nil) passthrough=\(terminal.passthroughMultiplexer != nil) probe=\(RemoteExecProbe.canProbe(terminal)) alt=\(Self.isAlternateScreenActive(terminal))") return false } Self.logger.debug("start: \(binding?.type.rawValue ?? "detect", privacy: .public)") stopTask?.cancel() stopTask = nil + validatingZmxBinding = false + // A zmx passthrough can be detached while the feed is in its short + // stop grace period. Its terminal binding is intentionally retained + // until a successful listing proves what happened, but a cached live + // feed must not make the next reveal bypass that validation. + let requiresZmxValidation = binding?.type == .zmx if loop != nil, self.terminal === terminal, + !requiresZmxValidation, binding == nil || (type == binding?.type && sessionName == binding?.sessionName) { // Re-opened inside the stop grace: the running feed still serves // this session, so its tabs and frames are already current. Self.logger.debug("reusing live feed: \(self.tabs.count) tabs, \(self.frames.count) frames") return true } + cancelFocus() teardownLoop() self.terminal = terminal resetSession() if let binding { - configure(binding) + if binding.type == .zmx { + // A cached zmx binding must be confirmed after a detach. + configure(binding) + validatingZmxBinding = true + state = .detecting + negativeDetectAt.removeValue(forKey: ObjectIdentifier(terminal)) + } else { + configure(binding) + } } else { type = nil sessionName = nil @@ -182,13 +203,17 @@ final class MultiplexerExposeFeed { return true } + /// Fast end of the pacing range for the adapter in hand. + private var floorInterval: TimeInterval { + max(Self.baseInterval, adapter?.minInterval ?? 0) + } + private func resetSession() { frames = [:] snapshot = nil hints = [:] lastHints = [:] lastFetchAt = [:] - // Pane ids are the previous session's; the tray reports afresh. visiblePanes = [] tickCount = 0 failures = 0 @@ -198,14 +223,11 @@ final class MultiplexerExposeFeed { } private func configure(_ binding: Ghostty.TerminalView.RawMultiplexerBinding) { + let sameMultiplexer = type == binding.type && adapter != nil type = binding.type sessionName = binding.sessionName - switch binding.type { - case .herdr: adapter = HerdrExposeAdapter() - case .tmux: adapter = TmuxExposeAdapter() - case .zellij: adapter = ZellijExposeAdapter() - } - // Quick re-entry: repaint the last picture while the first tick runs. + if !sameMultiplexer { adapter = Self.adapter(for: binding.type) } + interval = floorInterval if let terminal, let cache, cache.owner == ObjectIdentifier(terminal), cache.session == "\(binding.type.rawValue):\(binding.sessionName ?? "")", CACurrentMediaTime() - cache.at < Self.cacheLifetime { @@ -250,6 +272,12 @@ final class MultiplexerExposeFeed { sleeper = nil } + private func cancelFocus() { + focusTask?.cancel() + focusTask = nil + focusGeneration &+= 1 + } + /// Panes on screen right now; they are fetched first and every tick. func setVisiblePanes(_ ids: Set) { guard ids != visiblePanes else { return } @@ -263,15 +291,63 @@ final class MultiplexerExposeFeed { /// still runs while the exposé is already dismissing. func focus(tabID: String) { guard let terminal, let adapter else { return } - let script = adapter.focusScript(session: sessionName, tabID: tabID) - Task { [weak self] in + let session = sessionName + cancelFocus() + let focusRun = focusGeneration + + // Avoid resetting terminal state when the selected tab is already active. + guard session != tabID else { return } + + // Use the pane's own PTY when a shell is known to be available below zmx. + if type == .zmx, let sourceBinding = terminal.passthroughMultiplexer, + sourceBinding.canDetachSwitch, + let session, !session.isEmpty, session != tabID { + guard let zmxAdapter = adapter as? ZmxExposeAdapter, + let sourceClients = zmxAdapter.clientCount(for: session), + let targetClients = zmxAdapter.clientCount(for: tabID) + else { + Self.logger.warning("detach focus has no client census; leaving the pane untouched") + return + } + prepareForPassthroughSwitch(terminal) + performZmxDetachSwitch( + terminal: terminal, + session: session, + tabID: tabID, + sourceClients: sourceClients, + targetClients: targetClients, + sourceBinding: sourceBinding, + focusRun: focusRun + ) + return + } + + guard adapter.canFocus(session: session, tabID: tabID) else { + Self.logger.info("focus declined by the adapter; leaving the pane where it is") + return + } + let script = adapter.focusScript(session: session, tabID: tabID) + prepareForPassthroughSwitch(terminal) + focusTask = Task { [weak self, weak terminal] in + guard let self, let terminal else { return } for attempt in 0../dev/null", + nonce: "detach-census" + ) + + /// Move a detachable zmx pane by typing into its own PTY. + private func performZmxDetachSwitch( + terminal: Ghostty.TerminalView, + session: String, + tabID: String, + sourceClients: Int, + targetClients: Int, + sourceBinding: Ghostty.TerminalView.RawMultiplexerBinding, + focusRun: UInt64 + ) { + // Seed the title before typing so an echoed command cannot win the race. + let originalTitle = terminal.title + if terminal.userOverrideTitle == nil { + terminal.title = tabID + } + let inputLine = terminal.zmxAttachInputLine(sessionName: tabID) + focusTask = Task { [weak self, weak terminal] in + guard let self, let terminal else { return } + @MainActor func isCurrentFocus() -> Bool { + !Task.isCancelled && self.focusGeneration == focusRun + && self.terminal === terminal + && terminal.passthroughMultiplexer == sourceBinding + } + var confirmed = false + defer { + if !confirmed, self.focusGeneration == focusRun { + terminal.titleSuppressedUntil = nil + terminal.pendingCommandEcho = nil + if terminal.userOverrideTitle == nil, terminal.title == tabID { + terminal.title = originalTitle + } + } + } + var needsDetach = true + switchAttempts: for attempt in 0.. [String: Int]? { + for attempt in 0.. Ghostty.TerminalView.RawMultiplexerBinding? { guard let terminal else { return nil } + detectionWasConclusive = false + let altActive = Self.isAlternateScreenActive(terminal) + if !altActive, let last = negativeDetectAt[ObjectIdentifier(terminal)], + CACurrentMediaTime() - last < Self.negativeProbeCooldown { + return nil + } + // Scoped to the alt-inactive branch only: tmux/zellij/herdr were + // never gated by `canDetect` this way, so their probe cadence stays + // untouched. + func fail(conclusive: Bool = false) -> Ghostty.TerminalView.RawMultiplexerBinding? { + if conclusive { detectionWasConclusive = true } + if !altActive { + let now = CACurrentMediaTime() + negativeDetectAt = negativeDetectAt.filter { now - $0.value < Self.negativeProbeCooldown * 8 } + negativeDetectAt[ObjectIdentifier(terminal)] = now + } + return nil + } let tty = Self.localTTY(terminal) let nonce = Self.nonce() // Candidate multiplexer clients: on this tty locally, ours anywhere // otherwise. `grep` only narrows; the command name is checked here. let candidates = tty.map { "ps -t \(MuxScript.dq($0)) -o pid=,ppid=,tty=,args= 2>/dev/null" } - ?? "ps -xo pid=,ppid=,tty=,args= 2>/dev/null | grep -E \"tmux|herdr|zellij\" | grep -v grep" + ?? "ps -xo pid=,ppid=,tty=,args= 2>/dev/null | grep -E \"tmux|herdr|zellij|zmx\" | grep -v grep" let candidatePIDs = "$(\(candidates) | awk \"{print \\$1}\")" // Ancestor walks, so a candidate can be tied to THIS connection. let walk = "_q=$1; while [ -n \"$_q\" ] && [ \"$_q\" -gt 1 ] 2>/dev/null;" @@ -373,6 +656,10 @@ final class MultiplexerExposeFeed { body += "; echo \(MuxScript.dq(MuxScript.topology(nonce)))" body += "; \(candidates)" body += "; echo \"::MX_SELF::\"; _walk $$" + // OpenSSH gives every channel on one transport the same connection + // tuple. Keep it as a fallback for servers whose process tree does + // not leave the interactive and exec channels under a shared sshd. + body += "; echo \"::MX_CONNECTION::\"; printf \"%s\\n\" \"${SSH_CONNECTION-}\"" body += "; echo \"::MX_CHAINS::\"; for _p in \(candidatePIDs); do echo \"::MX_PID:$_p::\"; _walk \"$_p\"; done" body += "; echo \"::MX_CLIENTS::\"" body += "; tmux list-clients -F \"#{client_tty}\(TmuxExposeAdapter.separator)#{session_name}\" 2>/dev/null" @@ -383,17 +670,18 @@ final class MultiplexerExposeFeed { // has no path of its own, so its inode is matched in /proc/net/unix. // Linux servers routinely lack lsof, and a client socket there // usually has no name in it even when installed. - body += "; echo \"::MX_SOCKETS::\"; for _p in \(candidatePIDs); do" - body += " echo \"::MX_PID:$_p::\"; lsof -a -p \"$_p\" -U -F n 2>/dev/null;" - body += " if [ -d \"/proc/$_p/fd\" ]; then" - body += " for _i in $(ls -l \"/proc/$_p/fd\" 2>/dev/null" + body += "; echo \"::MX_SOCKETS::\"; for _p in \(candidatePIDs); do echo \"::MX_PID:$_p::\";" + body += " for _s in \"$_p\" $(ps -xo pid=,ppid= 2>/dev/null | awk -v p=\"$_p\" \"\\$2==p {print \\$1}\"); do" + body += " lsof -a -p \"$_s\" -U -F n 2>/dev/null;" + body += " if [ -d \"/proc/$_s/fd\" ]; then" + body += " for _i in $(ls -l \"/proc/$_s/fd\" 2>/dev/null" body += " | sed -n \"s/.*socket:\\[\\([0-9][0-9]*\\)\\].*/\\1/p\"); do" body += " awk -v i=\"$_i\" \"\\$7==i && \\$8 ~ /^\\// {print \\$8}\" /proc/net/unix 2>/dev/null;" - body += " done; fi; done" + body += " done; fi; done; done" // Exported by the user's own shell where it was used (`HERDR_SESSION=x herdr`). body += "; echo \"::MX_ENV::\"; for _p in \(candidatePIDs); do echo \"::MX_PID:$_p::\";" body += " [ -r \"/proc/$_p/environ\" ] && tr \"\\0\" \"\\n\" < \"/proc/$_p/environ\" 2>/dev/null" - body += " | grep -E \"^(HERDR_SESSION|HERDR_SOCKET_PATH|ZELLIJ_SESSION_NAME)=\"; done" + body += " | grep -E \"^(HERDR_SESSION|HERDR_SOCKET_PATH|ZELLIJ_SESSION_NAME|SSH_CONNECTION|\(TerminalIdentity.paneTokenVariable))=\"; done" // zellij's server runs as `zellij --server /`. body += "; echo \"::MX_SERVERS::\"; ps -xo pid=,ppid=,args= 2>/dev/null | grep -- \"--server\" | grep -v grep" @@ -402,22 +690,23 @@ final class MultiplexerExposeFeed { timeout: Self.tickTimeout, maxResponseBytes: 64 * 1024 ) else { Self.logger.debug("detect: probe failed") - return nil + return fail() } - let parts = ["::MX_SELF::", "::MX_CHAINS::", "::MX_CLIENTS::", "::MX_SOCKETS::", "::MX_ENV::", "::MX_SERVERS::"] + let parts = ["::MX_SELF::", "::MX_CONNECTION::", "::MX_CHAINS::", "::MX_CLIENTS::", "::MX_SOCKETS::", "::MX_ENV::", "::MX_SERVERS::"] .reduce([MuxScript.sections(of: output, nonce: nonce).topology]) { sections, marker in sections.flatMap { $0.components(separatedBy: marker) } } - guard parts.count >= 7 else { + guard parts.count >= 8 else { Self.logger.debug("detect: unexpected reply, \(output.count) bytes") - return nil + return fail() } let ownChain = Set(parts[1].split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) }) - let chains = Self.pidSections(parts[2]).mapValues { Set($0) } - let clients = parts[3] - let sockets = Self.pidSections(parts[4]) - let environments = Self.pidSections(parts[5]).mapValues { Self.environment($0) } - let servers = parts[6] + let ownSSHConnection = parts[2].trimmingCharacters(in: .whitespacesAndNewlines) + let chains = Self.pidSections(parts[3]).mapValues { Set($0) } + let clients = parts[4] + let sockets = Self.pidSections(parts[5]) + let environments = Self.pidSections(parts[6]).mapValues { Self.environment($0) } + let servers = parts[7] var found: [(binding: Ghostty.TerminalView.RawMultiplexerBinding, pid: String, tty: String)] = [] for line in parts[0].split(separator: "\n") { @@ -470,24 +759,104 @@ final class MultiplexerExposeFeed { ?? Self.zellijSession(in: sockets[pid] ?? []) ?? Self.zellijSession(servers: servers, clientPID: pid) binding = .init(type: .zellij, sessionName: session, hasOwnedAltScreen: true) + case "zmx": + // `zmx run` owns a detached session but is not a client + // attached to this pane. It may share the pane's SSH + // connection when it was launched from the same shell, so + // exclude it before applying connection correlation below. + if let subcommand = words.dropFirst().first, + subcommand == "run" || subcommand == "r" { + continue + } + // `switchSesh` (main.zig) recurses into `attach()` in the SAME + // process without ever re-exec'ing, so argv keeps naming + // whatever session this pane FIRST attached to, forever, + // through any number of later switches. The session's actual + // daemon -- forked, not exec'd, at attach and at every switch + // -- holds the live named socket instead, so it is asked + // first; argv is only a fallback for the moment before that + // fork's socket shows up here, or a host with neither `lsof` + // nor `/proc`. + var session = Self.zmxSession(in: sockets[pid] ?? []) + if session == nil, let index = words.firstIndex(where: { $0 == "attach" || $0 == "a" }), + index + 1 < words.count, !words[index + 1].hasPrefix("-") { + session = words[index + 1] + } + // Nothing to focus or to key the exposé's tab identity by + // without a name, and guessing one would risk moving a + // session this pane is not actually in -- same reasoning as + // the configured-binding decline in + // `applyConfiguredMultiplexerBinding`. + guard let session else { continue } + binding = .init(type: .zmx, sessionName: session, hasOwnedAltScreen: false) default: continue } found.append((binding, pid, processTTY)) } - guard !found.isEmpty else { return nil } - // On the pane's own tty there is nothing to disambiguate, and a lone - // client on the host is the one this pane is attached to. - if tty != nil || found.count == 1 { return found[0].binding } - // Otherwise keep only what shares this connection's sshd, and give up - // unless exactly one does: showing another session's tabs, or focusing - // one, is worse than falling back to the app tabs. - let related = found.filter { !(chains[$0.pid] ?? []).intersection(ownChain).isEmpty } - guard related.count == 1 else { + // Raw multiplexers own the alternate screen for the attach. zmx is a + // passthrough, so its inner application alone determines screen state. + found = found.filter { + MuxScreenGate.admits( + ownsAlternateScreen: $0.binding.type.ownsAlternateScreen, + alternateScreenActive: altActive + ) + } + guard !found.isEmpty else { return fail(conclusive: true) } + // On the pane's own tty there is nothing to disambiguate. + if tty != nil { return found[0].binding } + // Prefer the per-pane token Rootshel forwards with the interactive + // channel. Several terminal channels may share one SSH transport, so + // their processes also share the sshd ancestry and SSH_CONNECTION + // tuple; neither identifies WHICH pane owns a client. Servers that do + // not accept LC_* variables fall back to those coarser signals. + let paneToken = terminal.uuid.uuidString + let paneRelated = found.filter { + environments[$0.pid]?[TerminalIdentity.paneTokenVariable] == paneToken + } + + // Otherwise keep only what shares this connection's sshd or exact + // SSH_CONNECTION tuple, and give up unless they all agree on a single + // binding: showing another + // session's tabs, or focusing one, is worse than falling back to the + // app tabs. Plain agreement (not just a lone survivor) is the bar + // because of zmx: unlike tmux/zellij/herdr, whose client process IS + // the thing that ends up attached, zmx FORKS its session daemon as a + // child of the attaching client -- at attach and at every later + // switch -- rather than ever exec'ing into it. So one hand-typed + // `zmx a foo1` in a pane yields TWO candidates above that both share + // `ownChain` (the fork is a descendant of the client): the client + // itself and its forked daemon. Both resolve the same socket via + // `zmxSession(in:)` and so carry an identical binding -- that is + // corroboration, not ambiguity, and collapsing agreeing candidates + // before counting is what tells the two apart. (id=zmx-fork-collapse) + let related = paneRelated.isEmpty ? found.filter { + if !(chains[$0.pid] ?? []).intersection(ownChain).isEmpty { return true } + guard !ownSSHConnection.isEmpty else { return false } + return environments[$0.pid]?["SSH_CONNECTION"] == ownSSHConnection + } : paneRelated + guard !related.isEmpty else { + // The probe completed, but no candidate belongs to this + // connection: authoritative absence for a cached zmx binding. + return fail(conclusive: true) + } + guard let winner = Self.collapseAgreeingBindings(related.map(\.binding)) else { Self.logger.debug("detect: \(found.count) candidates, \(related.count) on this connection; not guessing") - return nil + return fail() } - return related[0].binding + return winner + } + + /// Reduces a list of binding candidates that share this connection down + /// to a single agreed-upon binding, or `nil` when the list is empty or + /// the candidates genuinely disagree. Generic over the candidate's own + /// equality rather than named against + /// `Ghostty.TerminalView.RawMultiplexerBinding` directly, keeping this + /// reduction independent of the transport-specific binding type. + /// (id=zmx-fork-collapse) + static func collapseAgreeingBindings(_ candidates: [Binding]) -> Binding? { + guard let first = candidates.first, candidates.allSatisfy({ $0 == first }) else { return nil } + return first } /// Lines grouped by the `::MX_PID:::` markers the script emits. @@ -563,6 +932,22 @@ final class MultiplexerExposeFeed { return nil } + /// Returns the most-referenced zmx session socket. A stable tie-break keeps + /// the client and its forked daemon aligned for candidate correlation. + private static func zmxSession(in paths: [String]) -> String? { + var hits: [String: Int] = [:] + for path in paths where path.hasPrefix("/") { + let directory = (path as NSString).deletingLastPathComponent + let dirName = (directory as NSString).lastPathComponent + guard dirName == "zmx" || dirName.hasPrefix("zmx-") else { continue } + let name = (path as NSString).lastPathComponent + guard !name.isEmpty else { continue } + hits[name, default: 0] += 1 + } + guard let highest = hits.values.max() else { return nil } + return hits.filter { $0.value == highest }.keys.sorted().first + } + /// The session name when the host runs exactly one; nil leaves the feed /// unusable rather than guessing (the caller applies the result). private func resolveSession() async -> String? { @@ -603,7 +988,7 @@ final class MultiplexerExposeFeed { // The reply describes the session this run was started for; a newer // run may already own the feed's frames and snapshot. guard isCurrent(generation) else { return .cancelled } - guard let result = adapter.parseTick(output: output, nonce: nonce) else { + guard let result = adapter.parseTick(output: output, session: sessionName, nonce: nonce) else { // Only the prelude's own verdict is final. Anything else (a // half-written reply, a momentarily unavailable server) is a // transient failure worth retrying, and never throws away a @@ -612,6 +997,15 @@ final class MultiplexerExposeFeed { Self.logger.debug("multiplexer reports unsupported") return .unsupported } + if type == .zmx, + !MuxScript.sections(of: output, nonce: nonce).truncated, + let zmx = adapter as? ZmxExposeAdapter, + let sessionName, + zmx.boundSessionIsUnavailable(sessionName) { + Self.logger.debug("zmx bound session is no longer attached") + clearCurrentPassthroughBinding() + return .unsupported + } Self.logger.debug("tick: unparseable reply, \(output.count) bytes") if snapshot == nil, failures >= 2 { return .unsupported } return failed("tick: unparseable") @@ -663,7 +1057,7 @@ final class MultiplexerExposeFeed { // First topology lands with no frames: fetch the visible set right away. if tickCount == 1 { return .immediate } if changed { - interval = Self.baseInterval + interval = floorInterval } else { interval = min(interval * 1.5, Self.maxInterval) } diff --git a/rootshell/Features/Multiplexer/Expose/TmuxExposeAdapter.swift b/rootshell/Features/Multiplexer/Expose/TmuxExposeAdapter.swift index 1236a9ca..b3fb6765 100644 --- a/rootshell/Features/Multiplexer/Expose/TmuxExposeAdapter.swift +++ b/rootshell/Features/Multiplexer/Expose/TmuxExposeAdapter.swift @@ -88,7 +88,7 @@ nonisolated struct TmuxExposeAdapter: MultiplexerExposeAdapter { ) } - func parseTick(output: String, nonce: String) -> MuxTickResult? { + func parseTick(output: String, session _: String?, nonce: String) -> MuxTickResult? { let sections = MuxScript.sections(of: output, nonce: nonce) guard sections.found, !sections.unsupported else { return nil } let rows = sections.topology.split(separator: "\n").compactMap(row) diff --git a/rootshell/Features/Multiplexer/Expose/ZellijExposeAdapter.swift b/rootshell/Features/Multiplexer/Expose/ZellijExposeAdapter.swift index d2c53df4..09bf05ba 100644 --- a/rootshell/Features/Multiplexer/Expose/ZellijExposeAdapter.swift +++ b/rootshell/Features/Multiplexer/Expose/ZellijExposeAdapter.swift @@ -45,7 +45,7 @@ nonisolated struct ZellijExposeAdapter: MultiplexerExposeAdapter { return MuxScript.wrap(body, nonce: nonce) } - func parseTick(output: String, nonce: String) -> MuxTickResult? { + func parseTick(output: String, session _: String?, nonce: String) -> MuxTickResult? { let sections = MuxScript.sections(of: output, nonce: nonce) guard sections.found, !sections.unsupported else { return nil } let halves = sections.topology.components(separatedBy: "::MX_PANES::") diff --git a/rootshell/Features/Multiplexer/Expose/ZmxExposeAdapter.swift b/rootshell/Features/Multiplexer/Expose/ZmxExposeAdapter.swift new file mode 100644 index 00000000..a02277c8 --- /dev/null +++ b/rootshell/Features/Multiplexer/Expose/ZmxExposeAdapter.swift @@ -0,0 +1,427 @@ +// +// ZmxExposeAdapter.swift +// rootshell +// + +import Foundation + +nonisolated struct ZmxExposeAdapter: MultiplexerExposeAdapter { + var type: MultiplexerType { .zmx } + + /// `zmx list` probes session sockets serially, so use a slower tick rate. + var minInterval: TimeInterval { 1.5 } + + /// Listed names already include `ZMX_SESSION_PREFIX`, so clear it before + /// passing those names to other zmx subcommands. + private static let prefix = "ZMX_SESSION_PREFIX= " + + /// Tail by lines so the VT prelude cannot change preview-surface modes. + private static let captureRows = 80 + + // MARK: - Scripts + + func resolveSessionScript(nonce: String) -> String { + // `--short` omits unreachable sessions. + MuxScript.wrap( + "echo \(MuxScript.dq(MuxScript.topology(nonce)))" + + "; \(Self.prefix)zmx list --short 2>/dev/null", + nonce: nonce + ) + } + + func tickScript(session: String?, request: MuxTickRequest, nonce: String) -> String { + var body = "command -v zmx >/dev/null 2>&1 || echo \(MuxScript.dq(MuxScript.unsupportedMarker))" + // Time out individual captures without truncating the serial listing. + body += "; _zt=\"\"; command -v timeout >/dev/null 2>&1 && _zt=\"timeout 2\"" + body += "; echo \(MuxScript.dq(MuxScript.topology(nonce)))" + body += "; echo \"::SESSIONS::\"" + body += "; \(Self.prefix)${_zt:+timeout 5} zmx list 2>/dev/null" + for name in request.fetch { + body += "; \(MuxScript.paneMarker(nonce: nonce, id: name))" + body += "; \(Self.prefix)$_zt zmx history \(MuxScript.dq(name)) --vt 2>/dev/null" + body += " | tail -n \(Self.captureRows)" + // History may end mid-line; keep the next section marker separate. + body += "; echo" + } + return MuxScript.wrap(body, nonce: nonce) + } + + /// Exec-channel switching targets zmx's leader client, not necessarily this + /// pane. Only use it when this pane is the session's sole client; detachable + /// panes use their own PTY instead. + func canFocus(session: String?, tabID: String) -> Bool { + guard let session, !session.isEmpty, session != tabID else { return true } + // The before/after census still verifies a switch when no count exists. + guard let clients = census.clients[session] else { return true } + return clients <= 1 + } + + func focusScript(session: String?, tabID: String) -> String { + guard let session, !session.isEmpty, session != tabID else { + return MuxScript.wrap("true", nonce: Self.focusNonce) + } + var body = "echo \(MuxScript.dq(MuxScript.topology(Self.focusNonce)))" + body += "; echo \(MuxScript.dq(Self.focusBeforeMarker)); \(Self.prefix)zmx list 2>/dev/null" + body += "; ZMX_SESSION=\(MuxScript.dq(session)) \(Self.prefix)zmx attach \(MuxScript.dq(tabID)) >/dev/null 2>&1" + // The switch is fire-and-forget; wait before collecting confirmation. + body += "; sleep 0.3" + body += "; echo \(MuxScript.dq(Self.focusAfterMarker)); \(Self.prefix)zmx list 2>/dev/null" + return MuxScript.wrap(body, nonce: Self.focusNonce) + } + + /// Confirms the fire-and-forget switch by comparing client counts. + func parseFocusResult(output: String, session: String?, tabID: String) -> Bool { + guard let session, !session.isEmpty, session != tabID else { + return true + } + let sections = MuxScript.sections(of: output, nonce: Self.focusNonce) + guard sections.found else { return false } + guard let beforeSplit = sections.topology.range(of: Self.focusBeforeMarker) else { return false } + let afterTopology = sections.topology[beforeSplit.upperBound...] + guard let afterSplit = afterTopology.range(of: Self.focusAfterMarker) else { return false } + let beforeText = String(afterTopology[.. 0 + else { return false } + guard let afterSessionClients = after.first(where: { $0.name == session })?.clientCount, + let beforeTargetClients = before.first(where: { $0.name == tabID })?.clientCount, + let afterTargetClients = after.first(where: { $0.name == tabID })?.clientCount + else { return false } + + return afterSessionClients < beforeSessionClients && afterTargetClients > beforeTargetClients + } + + private static let focusNonce = "focus" + private static let focusBeforeMarker = "::MX_FOCUS_BEFORE::" + private static let focusAfterMarker = "::MX_FOCUS_AFTER::" + + // MARK: - Parsing + + func parseTick(output: String, session: String?, nonce: String) -> MuxTickResult? { + let sections = MuxScript.sections(of: output, nonce: nonce) + guard sections.found, !sections.unsupported else { return nil } + + let sessions = ZmxDiscoveryParser.parse(output: sections.topology) + census.sessions = Set(sessions.map(\.name)) + guard !sessions.isEmpty else { return nil } + + // Feeds `focusScript`'s leader guard from the listing already in hand. + census.clients = sessions.reduce(into: [:]) { counts, info in + if let clients = info.clientCount { counts[info.name] = clients } + } + + // A zmx detach leaves the underlying shell/connection alive, so the + // terminal's passthrough binding does not get the normal + // `sessionDidEnd()` teardown. The bound session having no clients is + // the one reliable indication that this pane is no longer inside zmx; + // make the tick unusable so the feed can release that stale identity. + // Keep an absent count as unknown for compatibility with older zmx + // output rather than falsely dropping a live binding. + if !sections.truncated, + let session, + boundSessionIsUnavailable(session) { return nil } + + var frames: [String: MuxPaneFrame] = [:] + for section in sections.panes { + let screen = Self.visibleScreen(section.body) + guard !screen.isEmpty else { continue } + let measured = Self.measure(screen) + let restoredState = Self.restoredTerminalState(section.body) + // Ignore captures whose row count may be limited by the history cap. + let rows: Int? = { + if restoredState.alternateScreen { + return measured.rows < Self.captureRows ? measured.rows : nil + } + if let cursorRow = restoredState.cursorRow { + return max(cursorRow, Self.minRows) + } + return measured.rows < Self.captureRows ? measured.rows : nil + }() + if let rows { + geometryCache.record(session: section.id, cols: measured.cols, rows: rows) + } + frames[section.id] = MuxPaneFrame( + // SGR state is threaded across the whole capture and the tail + // cut the opening of it; close it so one session's colour + // cannot bleed past its own cell. + ansi: screen + "\u{1B}[0m", + cursor: nil, + revision: MuxExposeIdentity.contentRevision(screen) + ) + } + // A session `zmx list` no longer reports can never be recorded again, + // so its window would otherwise sit in the cache forever. + geometryCache.evict(keepingOnly: Set(sessions.map(\.name))) + + let tabs = sessions.enumerated().map { index, info in + let measuredGrid = geometryCache.grid(for: info.name) + // A session zmx has never attached to has no tty from which to + // learn a size. zmx creates that terminal at its own 120x24 + // fallback, but a sparse screen (a shell prompt or quiet agent) + // exposes only its occupied cells in `history --vt`. Treating + // that content width as terminal width collapses the preview to + // our ordinary 80-column floor until the first attach forces a + // redraw. While clients=0, preserve zmx's exact fallback width; + // still honor a wider measured capture when one exists. + let grid = ( + cols: info.clientCount == 0 + ? max(measuredGrid.cols, Self.fallbackGrid.cols) + : measuredGrid.cols, + rows: measuredGrid.rows + ) + return MuxTab( + id: info.name, + index: index, + title: info.name, + isActive: info.name == session, + cols: grid.cols, + rows: grid.rows, + panes: [ + MuxPane( + id: info.name, + rect: MuxCellRect(x: 0, y: 0, width: grid.cols, height: grid.rows), + isActive: true, + isPreviewable: true, + title: info.command + ) + ], + badge: Self.badge(for: info) + ) + } + + return MuxTickResult( + snapshot: MuxExposeSnapshot(tabs: tabs, activeTabID: session), + frames: frames, + unchanged: [], + truncated: sections.truncated + ) + } + + // MARK: - Geometry + + /// zmx's own fallback when no client TTY supplies a size. + private static let fallbackGrid = (cols: 120, rows: 24) + + /// Bucket the reported grid rounds up to, so a row that grows by a few + /// columns between ticks does not change `MuxTab` (see `fallbackGrid`'s + /// doc for why that matters). + private static let colBucket = 16 + private static let rowBucket = 8 + private static let minCols = 80 + private static let minRows = 24 + /// Generous but bounded, so a pathological capture cannot allocate an + /// unreasonably large offscreen surface. + private static let maxCols = 320 + /// `captureRows` already bounds how many rows a capture can contain; this + /// just restates that bound as the geometry ceiling. + private static let maxRows = captureRows + + private static func quantize(_ value: Int, bucket: Int, floor: Int, ceiling: Int) -> Int { + let rounded = ((max(value, 0) + bucket - 1) / bucket) * bucket + return min(max(rounded, floor), ceiling) + } + + /// Per-session geometry, confined to the feed's serialized tick loop. + private final class GeometryCache: @unchecked Sendable { + private struct Observation { let cols: Int; let rows: Int } + private var windows: [String: [Observation]] = [:] + /// Smooths a momentary narrow/wide frame while still following a real + /// resize down within a handful of ticks, once the old high ages out. + private static let windowSize = 4 + + /// The grid currently in effect for `session`: the observation + /// window's max, quantized; `fallbackGrid` if nothing was ever + /// recorded for it. + func grid(for session: String) -> (cols: Int, rows: Int) { + guard let window = windows[session], !window.isEmpty else { return ZmxExposeAdapter.fallbackGrid } + let cols = window.map(\.cols).max() ?? ZmxExposeAdapter.fallbackGrid.cols + let rows = window.map(\.rows).max() ?? ZmxExposeAdapter.fallbackGrid.rows + return ( + ZmxExposeAdapter.quantize(cols, bucket: ZmxExposeAdapter.colBucket, floor: ZmxExposeAdapter.minCols, ceiling: ZmxExposeAdapter.maxCols), + ZmxExposeAdapter.quantize(rows, bucket: ZmxExposeAdapter.rowBucket, floor: ZmxExposeAdapter.minRows, ceiling: ZmxExposeAdapter.maxRows) + ) + } + + /// Slides in a fresh measurement from this tick's capture of `session`. + func record(session: String, cols: Int, rows: Int) { + var window = windows[session] ?? [] + window.append(Observation(cols: cols, rows: rows)) + if window.count > Self.windowSize { window.removeFirst(window.count - Self.windowSize) } + windows[session] = window + } + + /// Drops sessions `zmx list` no longer reports, so this cannot grow + /// without bound across a host's session churn. + func evict(keepingOnly live: Set) { + windows = windows.filter { live.contains($0.key) } + } + } + + private let geometryCache = GeometryCache() + + /// Latest client census, confined to the feed's serialized tick loop. + private final class Census: @unchecked Sendable { + var clients: [String: Int] = [:] + var sessions: Set = [] + } + + private let census = Census() + + /// Client count captured by the latest topology tick. The detach-based + /// focus path snapshots these two values before it writes to the pane, + /// then confirms the transfer with a fresh `zmx list` rather than + /// optimistically changing the pane's binding. + func clientCount(for session: String) -> Int? { + census.clients[session] + } + + /// Whether a completed listing proves that the pane's bound zmx session + /// is no longer attached. A missing client count remains unknown; old zmx + /// versions did not always emit that field. + func boundSessionIsUnavailable(_ session: String) -> Bool { + !census.sessions.contains(session) || census.clients[session] == 0 + } + + /// Cols/rows the capture's content actually needs: the widest row's + /// display width and the row count. See `fallbackGrid`'s doc for why this + /// is a sound measurement. + static func measure(_ screen: String) -> (cols: Int, rows: Int) { + // Split scalars because Swift treats CRLF as one Character. + var rows: [[Unicode.Scalar]] = [[]] + for scalar in screen.unicodeScalars { + if scalar == "\n" { + rows.append([]) + } else { + rows[rows.count - 1].append(scalar) + } + } + var widest = 0 + for var row in rows { + if row.last == "\r" { row.removeLast() } + var view = String.UnicodeScalarView() + view.append(contentsOf: row) + widest = max(widest, DisplayWidth.width(of: Self.stripAnsi(String(view)))) + } + return (widest, rows.count) + } + + /// Terminal state that zmx's VT formatter appends to a history replay. + /// + /// The final CUP (`CSI row;column H`) restores the cursor. On the primary + /// screen it is the only reliable row witness when the capture also holds + /// scrollback. Alternate-screen mode (`DECSET 47/1047/1049`) means the + /// captured rows are the TUI's screen instead, so `parseTick` keeps using + /// the measured screen height there. + static func restoredTerminalState(_ capture: String) -> (cursorRow: Int?, alternateScreen: Bool) { + let bytes = Array(capture.utf8) + var cursorRow: Int? + var alternateScreen = false + var index = 0 + + while index + 2 < bytes.count { + guard bytes[index] == 0x1B, bytes[index + 1] == 0x5B else { + index += 1 + continue + } + + var end = index + 2 + while end < bytes.count, !(0x40...0x7E).contains(bytes[end]) { + end += 1 + } + guard end < bytes.count else { break } + + let final = bytes[end] + let parameterBytes = bytes[(index + 2).. String { + var result = "" + result.reserveCapacity(text.count) + var iterator = text.makeIterator() + while let character = iterator.next() { + guard character == "\u{1B}" else { + result.append(character) + continue + } + guard let introducer = iterator.next() else { break } + switch introducer { + case "[": + // CSI: skip parameter/intermediate bytes until a final byte (0x40-0x7E). + while let byte = iterator.next() { + if let ascii = byte.asciiValue, (0x40...0x7E).contains(ascii) { break } + } + case "]": + // OSC: terminated by BEL or ST (ESC \). + var previous: Character? + while let byte = iterator.next() { + if byte == "\u{07}" { break } + if previous == "\u{1B}", byte == "\\" { break } + previous = byte + } + case "P", "X", "^", "_": + // DCS/SOS/PM/APC: terminated by ST (ESC \). + var previous: Character? + while let byte = iterator.next() { + if previous == "\u{1B}", byte == "\\" { break } + previous = byte + } + case "(", ")", "*", "+", "-", ".", "/": + // Character-set designation: one more byte names the set + // (`ESC ( 0` into line-drawing, `ESC ( B` back to ASCII). + _ = iterator.next() + default: + // Two-character escape (ESC 7, ESC c, ...) -- already consumed. + break + } + } + return result + } + + /// The capture reduced to what a screen would show. + /// + /// A full-screen program clears before drawing, so the bytes after the last + /// erase-display are that program's screen and everything before it is + /// scrollback. Without a clear the tail already stands in for the screen. + static func visibleScreen(_ capture: String) -> String { + var text = capture + if let lastClear = text.range(of: "\u{1B}[2J", options: .backwards) { + text = String(text[lastClear.upperBound...]) + } + while text.hasPrefix("\n") || text.hasPrefix("\r") { text.removeFirst() } + while text.hasSuffix("\n") || text.hasSuffix("\r") { text.removeLast() } + return text + } + + /// Trailing caption note. The client count is the one fact worth surfacing: + /// zmx routes a switch to the session's LEADER client, so a session another + /// terminal also holds may move that terminal instead of this pane. + private static func badge(for info: ZmxSessionInfo) -> String? { + guard let clients = info.clientCount, clients > 1 else { return nil } + return String( + format: String(localized: "%d clients", comment: "zmx exposé cell badge, attached client count"), + clients + ) + } +} diff --git a/rootshell/Features/Profiles/ConnectionProfileManager.swift b/rootshell/Features/Profiles/ConnectionProfileManager.swift index b35d9e14..61383096 100644 --- a/rootshell/Features/Profiles/ConnectionProfileManager.swift +++ b/rootshell/Features/Profiles/ConnectionProfileManager.swift @@ -242,6 +242,7 @@ final class ConnectionProfileManager { sshConfig.tmuxAutoEnable = historyEntry.tmuxAutoEnable ?? false sshConfig.tmuxAutoMode = historyEntry.tmuxAutoMode ?? .regular sshConfig.herdrAutoEnable = historyEntry.herdrAutoEnable ?? false + sshConfig.zmxAutoEnable = historyEntry.zmxAutoEnable ?? false sshConfig.launchCommand = historyEntry.launchCommand sshConfig.launchCommandMode = historyEntry.launchCommandMode ?? .afterConnect sshConfig.terminalType = historyEntry.terminalType diff --git a/rootshell/Features/Profiles/Views/ProfileEditorSheet.swift b/rootshell/Features/Profiles/Views/ProfileEditorSheet.swift index 62be6f77..5798ee39 100644 --- a/rootshell/Features/Profiles/Views/ProfileEditorSheet.swift +++ b/rootshell/Features/Profiles/Views/ProfileEditorSheet.swift @@ -92,6 +92,7 @@ struct ProfileEditorSheet: View { // herdr auto-attach (mutually exclusive with enableTmux via the picker) @State private var enableHerdr: Bool = false + @State private var enableZmx: Bool = false // Globals the multiplexer captions fall back to. Observed rather than read // directly so the captions refresh when Settings change; the values @@ -1305,15 +1306,17 @@ struct ProfileEditorSheet: View { connectionProtocol == .mosh ? .regular : tmuxAutoMode } - /// Bridges the stored `(enableTmux, tmuxAutoMode, enableHerdr)` fields to a + /// Bridges the stored `(enableTmux, tmuxAutoMode, enableHerdr, enableZmx)` fields to a /// single Picker. Control mode can't run over Mosh, so it's presented as /// regular there. private var tmuxLaunchSelection: Binding { Binding( - get: { TmuxLaunchSelection(tmuxEnabled: enableTmux, mode: effectiveTmuxAutoMode, herdrEnabled: enableHerdr) }, + get: { TmuxLaunchSelection(tmuxEnabled: enableTmux, mode: effectiveTmuxAutoMode, + herdrEnabled: enableHerdr, zmxEnabled: enableZmx) }, set: { sel in enableTmux = sel.tmuxEnabled enableHerdr = sel.herdrEnabled + enableZmx = sel.zmxEnabled if sel.tmuxEnabled { tmuxAutoMode = sel.mode } } ) @@ -1351,6 +1354,7 @@ struct ProfileEditorSheet: View { Text("tmux -CC (control)").tag(TmuxLaunchSelection.control) } Text("herdr").tag(TmuxLaunchSelection.herdr) + Text("zmx").tag(TmuxLaunchSelection.zmx) } .pickerStyle(.menu) .themedRow() @@ -1386,6 +1390,11 @@ struct ProfileEditorSheet: View { .font(.caption) .foregroundColor(.secondary) .themedRow() + } else if enableZmx { + Text("Attach to or create zmx session \"\(multiplexerCaptionSessionName)\" on connect") + .font(.caption) + .foregroundColor(.secondary) + .themedRow() } NavigationLink { @@ -1858,6 +1867,7 @@ struct ProfileEditorSheet: View { enableTmux = config.tmuxAutoEnable tmuxAutoMode = config.tmuxAutoMode enableHerdr = config.herdrAutoEnable + enableZmx = config.zmxAutoEnable // Load launch command launchCommand = config.launchCommand ?? "" @@ -1976,6 +1986,7 @@ struct ProfileEditorSheet: View { enableTmux = entry.tmuxAutoEnable ?? false tmuxAutoMode = entry.tmuxAutoMode ?? .regular enableHerdr = entry.herdrAutoEnable ?? false + enableZmx = entry.zmxAutoEnable ?? false // Launch command from history launchCommand = entry.launchCommand ?? "" @@ -2140,6 +2151,7 @@ struct ProfileEditorSheet: View { var finalConfig = sshConfig finalConfig.authMethod = sshAuthMethod finalConfig.herdrAutoEnable = enableHerdr + finalConfig.zmxAutoEnable = enableZmx // TERM override. Empty (or malformed) means inherit the global default // rather than pinning a value that would silently fall back anyway. @@ -2435,6 +2447,8 @@ private struct ProfileMultiplexerSessionEditor: View { @AppStorage("tmuxCustomCommand") private var tmuxCustomCommandSetting = "" @AppStorage("herdrSessionName") private var herdrSessionNameSetting = "" @AppStorage("herdrCustomCommand") private var herdrCustomCommandSetting = "" + @AppStorage("zmxSessionName") private var zmxSessionNameSetting = "" + @AppStorage("zmxCustomCommand") private var zmxCustomCommandSetting = "" @State private var isCustom: Bool @@ -2447,6 +2461,7 @@ private struct ProfileMultiplexerSessionEditor: View { } private var isHerdr: Bool { selection == .herdr } + private var isZmx: Bool { selection == .zmx } private var trimmed: String { sessionName.trimmingCharacters(in: .whitespacesAndNewlines) @@ -2459,20 +2474,42 @@ private struct ProfileMultiplexerSessionEditor: View { let name = herdrSessionNameSetting.trimmingCharacters(in: .whitespacesAndNewlines) return SSHConfig.isEmbeddableHerdrSessionName(name) ? name : "default" } + if isZmx { + let name = zmxSessionNameSetting.trimmingCharacters(in: .whitespacesAndNewlines) + return SSHConfig.isEmbeddableZmxSessionName(name) ? name : SSHConfig.zmxDefaultSessionName + } let name = tmuxSessionNameSetting.trimmingCharacters(in: .whitespacesAndNewlines) return name.isEmpty ? "main" : name } + /// The Settings command override for whichever multiplexer the picker + /// selected. Reading the wrong one here would disable the field for a + /// command the connection will never run. + private var customCommandSetting: String { + if isHerdr { return herdrCustomCommandSetting } + if isZmx { return zmxCustomCommandSetting } + return tmuxCustomCommandSetting + } + /// A full-command override in Settings replaces the session name outright, /// so pinning one here would do nothing. private var hasCustomCommand: Bool { - let command = isHerdr ? herdrCustomCommandSetting : tmuxCustomCommandSetting - return !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + !customCommandSetting.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private var customWarning: String? { - guard isCustom, !trimmed.isEmpty, - !SSHConfig.isEmbeddableMultiplexerSessionName(trimmed) else { return nil } + guard isCustom, !trimmed.isEmpty else { return nil } + if isZmx { + guard !SSHConfig.isEmbeddableZmxSessionName(trimmed) else { return nil } + // A leading `-` passes the shared charset rule but `zmx attach` + // reads it as a flag, so name the real problem rather than sending + // the user hunting for an illegal character that isn't there. + if trimmed.hasPrefix("-") { + return String(localized: "zmx reads a name starting with '-' as an option. This name falls back to the global default.") + } + return String(localized: "Use letters, digits, and . _ - only, up to 64 characters. Other names fall back to the global default.") + } + guard !SSHConfig.isEmbeddableMultiplexerSessionName(trimmed) else { return nil } return String(localized: "Use letters, digits, and . _ - only, up to 64 characters. Other names fall back to the global default.") } @@ -2516,6 +2553,8 @@ private struct ProfileMultiplexerSessionEditor: View { Text("Ignored when a custom auto-start command is set in Settings.") } else if isHerdr { Text("The herdr session this profile attaches to on connect. Leave on the global default to use the session name from Settings. Names may use letters, numbers, '.', '_' and '-' (\".\" and \"..\" alone are reserved).") + } else if isZmx { + Text("The zmx session this profile attaches to or creates on connect. Leave on the global default to use the session name from Settings. Names may use letters, numbers, '.', '_' and '-', and may not start with '-'.") } else { Text("The tmux session this profile attaches to or creates on connect. Leave on the global default to use the session name from Settings. A pinned name also overrides the session you were last attached to on this host.") } diff --git a/rootshell/Features/SSH/Config/HistoryExtensionPayload.swift b/rootshell/Features/SSH/Config/HistoryExtensionPayload.swift index f3df880c..97beeeef 100644 --- a/rootshell/Features/SSH/Config/HistoryExtensionPayload.swift +++ b/rootshell/Features/SSH/Config/HistoryExtensionPayload.swift @@ -34,12 +34,15 @@ import Foundation /// from the record being replaced. struct HistoryExtensionPayload: Codable, Hashable, Sendable { /// Envelope schema version. Bump on every added member. - static let currentVersion = 2 + static let currentVersion = 3 /// Envelope version that introduced `multiplexerSessionName`. Writers below /// this predate the field, so their nil is a gap, not a clear. static let multiplexerSessionNameVersion = 2 + /// Envelope version that introduced `zmxAutoEnable`. Same rule. + static let zmxAutoEnableVersion = 3 + var version: Int /// Per-connection `TERM` override. nil means the connection inherits the @@ -50,18 +53,29 @@ struct HistoryExtensionPayload: Codable, Hashable, Sendable { /// global default for whichever multiplexer auto-start selects. var multiplexerSessionName: String? + /// Whether zmx auto-start is on for this connection. + /// + /// Deliberately here rather than as a top-level `CKRecord` field, unlike the + /// `herdrAutoEnable` line sitting right beside it in `CloudKitSyncable`. A + /// new top-level field is not free: it needs a production CloudKit schema + /// deploy, which is exactly the cost this envelope was introduced to stop + /// paying. + var zmxAutoEnable: Bool? + init( version: Int = HistoryExtensionPayload.currentVersion, terminalType: String? = nil, - multiplexerSessionName: String? = nil + multiplexerSessionName: String? = nil, + zmxAutoEnable: Bool? = nil ) { self.version = version self.terminalType = terminalType self.multiplexerSessionName = multiplexerSessionName + self.zmxAutoEnable = zmxAutoEnable } private enum CodingKeys: String, CodingKey { - case version, terminalType, multiplexerSessionName + case version, terminalType, multiplexerSessionName, zmxAutoEnable } init(from decoder: Decoder) throws { @@ -73,6 +87,7 @@ struct HistoryExtensionPayload: Codable, Hashable, Sendable { // Field-level tolerance: a bad member must not sink the envelope. terminalType = (try? container.decodeIfPresent(String.self, forKey: .terminalType)) ?? nil multiplexerSessionName = (try? container.decodeIfPresent(String.self, forKey: .multiplexerSessionName)) ?? nil + zmxAutoEnable = (try? container.decodeIfPresent(Bool.self, forKey: .zmxAutoEnable)) ?? nil } func encode(to encoder: Encoder) throws { @@ -80,5 +95,6 @@ struct HistoryExtensionPayload: Codable, Hashable, Sendable { try container.encode(version, forKey: .version) try container.encodeIfPresent(terminalType, forKey: .terminalType) try container.encodeIfPresent(multiplexerSessionName, forKey: .multiplexerSessionName) + try container.encodeIfPresent(zmxAutoEnable, forKey: .zmxAutoEnable) } } diff --git a/rootshell/Features/SSH/Config/SSHCommandParser.swift b/rootshell/Features/SSH/Config/SSHCommandParser.swift index 75c60a26..c310d7f5 100644 --- a/rootshell/Features/SSH/Config/SSHCommandParser.swift +++ b/rootshell/Features/SSH/Config/SSHCommandParser.swift @@ -41,6 +41,7 @@ struct SSHCommandParser { var tmuxAutoEnable: Bool = false var tmuxAutoMode: TmuxAutoMode = .regular var herdrAutoEnable: Bool = false + var zmxAutoEnable: Bool = false var remoteCommand: String? var remoteCommandPolicy: SSHConfig.RemoteCommandPolicy = .verbatim /// Per-profile multiplexer session name, carried through the password @@ -62,6 +63,7 @@ struct SSHCommandParser { tmuxAutoMode: tmuxAutoMode ) config.herdrAutoEnable = herdrAutoEnable + config.zmxAutoEnable = zmxAutoEnable config.multiplexerSessionName = multiplexerSessionName config.remoteCommand = remoteCommand config.remoteCommandPolicy = remoteCommandPolicy @@ -104,6 +106,7 @@ struct SSHCommandParser { var remoteForwards: [PortForwardConfig.PortForward] = [] var tmuxAutoEnable = false var herdrAutoEnable = false + var zmxAutoEnable = false var remoteCommand: String? var remoteCommandPolicy: SSHConfig.RemoteCommandPolicy = .verbatim @@ -201,6 +204,10 @@ struct SSHCommandParser { // Enable herdr auto-attach herdrAutoEnable = true + case "--zmx": + // Enable zmx auto-attach + zmxAutoEnable = true + case Self.prependPATHFlag: // Rootshell-specific opt-in that preserves the old PATH wrapper // for shell-launched SSH exec requests. @@ -322,6 +329,7 @@ struct SSHCommandParser { tmuxAutoEnable: tmuxAutoEnable ) config.herdrAutoEnable = herdrAutoEnable + config.zmxAutoEnable = zmxAutoEnable config.remoteCommand = remoteCommand config.remoteCommandPolicy = remoteCommandPolicy return .success(config) @@ -354,6 +362,7 @@ struct SSHCommandParser { tmuxAutoEnable: tmuxAutoEnable ) config.herdrAutoEnable = herdrAutoEnable + config.zmxAutoEnable = zmxAutoEnable config.remoteCommand = remoteCommand config.remoteCommandPolicy = remoteCommandPolicy return .success(config) @@ -374,6 +383,7 @@ struct SSHCommandParser { tmuxAutoEnable: tmuxAutoEnable ) config.herdrAutoEnable = herdrAutoEnable + config.zmxAutoEnable = zmxAutoEnable config.remoteCommand = remoteCommand config.remoteCommandPolicy = remoteCommandPolicy return .success(config) @@ -399,6 +409,7 @@ struct SSHCommandParser { tmuxAutoEnable: tmuxAutoEnable ) config.herdrAutoEnable = herdrAutoEnable + config.zmxAutoEnable = zmxAutoEnable config.remoteCommand = remoteCommand config.remoteCommandPolicy = remoteCommandPolicy return .success(config) @@ -416,6 +427,7 @@ struct SSHCommandParser { cachedIP: cachedIP, tmuxAutoEnable: tmuxAutoEnable, herdrAutoEnable: herdrAutoEnable, + zmxAutoEnable: zmxAutoEnable, remoteCommand: remoteCommand, remoteCommandPolicy: remoteCommandPolicy ) diff --git a/rootshell/Features/SSH/Config/SSHConfig.swift b/rootshell/Features/SSH/Config/SSHConfig.swift index 02c4b197..3cb27320 100644 --- a/rootshell/Features/SSH/Config/SSHConfig.swift +++ b/rootshell/Features/SSH/Config/SSHConfig.swift @@ -25,21 +25,21 @@ extension TmuxAutoMode { } } -/// UI-facing selection for the "Auto-start multiplexer" menu, bridging the -/// stored `(tmuxAutoEnable, tmuxAutoMode, herdrAutoEnable)` fields to a single -/// Picker selection. tmux and herdr auto-start are mutually exclusive: only -/// one exec command can win at connect. +/// UI-facing selection for the mutually exclusive auto-start options. enum TmuxLaunchSelection: Hashable, CaseIterable { case off case regular case control case herdr + case zmx - init(tmuxEnabled: Bool, mode: TmuxAutoMode, herdrEnabled: Bool) { + init(tmuxEnabled: Bool, mode: TmuxAutoMode, herdrEnabled: Bool, zmxEnabled: Bool) { if tmuxEnabled { self = (mode == .control) ? .control : .regular } else if herdrEnabled { self = .herdr + } else if zmxEnabled { + self = .zmx } else { self = .off } @@ -51,6 +51,9 @@ enum TmuxLaunchSelection: Hashable, CaseIterable { /// Whether herdr auto-start is on. var herdrEnabled: Bool { self == .herdr } + /// Whether zmx auto-start is on. + var zmxEnabled: Bool { self == .zmx } + /// The persisted tmux launch mode (regular when tmux is off, which is /// irrelevant then). var mode: TmuxAutoMode { self == .control ? .control : .regular } @@ -147,6 +150,7 @@ struct SSHConfig: Codable, Hashable { /// connect. Mutually exclusive with `tmuxAutoEnable` in the UI; when both /// are somehow set, tmux wins. var herdrAutoEnable: Bool = false + var zmxAutoEnable: Bool = false /// Session name for whichever multiplexer the auto-start picker selects. /// nil or empty falls back to the global default, which is how every @@ -547,7 +551,7 @@ struct SSHConfig: Codable, Hashable { private enum CodingKeys: String, CodingKey { case host, port, username, authMethod, cachedIP, jumpHost case hssShorthand, cloudInstanceLabel, agentConfig, gpgAgentConfig, portForwardConfig - case tmuxAutoEnable, tmuxAutoMode, herdrAutoEnable, launchCommand, launchCommandMode, fallbackKeyIDs, keyResolutionHints + case tmuxAutoEnable, tmuxAutoMode, herdrAutoEnable, zmxAutoEnable, launchCommand, launchCommandMode, fallbackKeyIDs, keyResolutionHints case terminalType, multiplexerSessionName } @@ -568,6 +572,7 @@ struct SSHConfig: Codable, Hashable { tmuxAutoEnable = try container.decodeIfPresent(Bool.self, forKey: .tmuxAutoEnable) ?? false tmuxAutoMode = try container.decodeIfPresent(TmuxAutoMode.self, forKey: .tmuxAutoMode) ?? .regular herdrAutoEnable = try container.decodeIfPresent(Bool.self, forKey: .herdrAutoEnable) ?? false + zmxAutoEnable = try container.decodeIfPresent(Bool.self, forKey: .zmxAutoEnable) ?? false launchCommand = try container.decodeIfPresent(String.self, forKey: .launchCommand) launchCommandMode = try container.decodeIfPresent(LaunchCommandMode.self, forKey: .launchCommandMode) ?? .afterConnect fallbackKeyIDs = try container.decodeIfPresent([UUID].self, forKey: .fallbackKeyIDs) @@ -707,11 +712,6 @@ struct SSHConfig: Codable, Hashable { .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } - /// herdr session names are ASCII alphanumerics plus `.` `_` `-`, at most - /// 64 bytes, and never the reserved `.`/`..` (herdr's validate_name - /// rejects those). Anything else can't be embedded in the single-quoted - /// exec line — or would make herdr exit instead of attaching — and - /// degrades to the default session. static func isEmbeddableHerdrSessionName(_ name: String) -> Bool { !name.isEmpty && name != "." && name != ".." && name.utf8.count <= 64 && name.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "." || $0 == "_" || $0 == "-") @@ -727,13 +727,14 @@ struct SSHConfig: Codable, Hashable { return name } - /// Strictest of the two multiplexers' rules, so one stored value stays - /// legal for whichever the picker selects: herdr's rule is tmux's charset - /// plus a 64-byte cap and the reserved `.`/`..` names. static func isEmbeddableMultiplexerSessionName(_ name: String) -> Bool { isEmbeddableHerdrSessionName(name) } + static func isEmbeddableZmxSessionName(_ name: String) -> Bool { + isEmbeddableMultiplexerSessionName(name) && !name.hasPrefix("-") + } + /// Builds the `sh -c '...'` line that attaches to (or creates) a herdr /// session, falling back to `$SHELL` when herdr is missing. herdr's bare /// launch is attach-or-create, so no `-A` analogue is needed. @@ -783,6 +784,58 @@ struct SSHConfig: Codable, Hashable { return Self.herdrExecCommandLine(sessionName: herdrRawSessionNameForConnection) } + // MARK: - zmx auto-start + + /// zmx has no default-session concept: `zmx attach` with no name is an + /// error, so unlike herdr the fallback has to be a real name. + static let zmxDefaultSessionName = "main" + + /// Globally-configured zmx session name, falling back to `main`. + static var zmxGlobalSessionName: String { + let raw = UserDefaults.standard.string(forKey: "zmxSessionName")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return raw.isEmpty ? zmxDefaultSessionName : raw + } + + /// Builds the attach-or-create command, falling back to `$SHELL`. + static func zmxExecCommandLine(sessionName: String) -> String { + let name = isEmbeddableZmxSessionName(sessionName) ? sessionName : zmxDefaultSessionName + // Listed names already contain any configured session prefix. + return "sh -c '\(remoteExecPathPrefix)command -v zmx >/dev/null" + + " && ZMX_SESSION_PREFIX= exec zmx attach \(name)" + + " || exec $SHELL'" + } + + /// Shared zmx exec command used by all session types. + /// Reads "zmxCustomCommand" and "zmxSessionName" from UserDefaults. + static var zmxExecCommand: String { + if let custom = UserDefaults.standard.string(forKey: "zmxCustomCommand"), + !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return custom + } + return zmxExecCommandLine(sessionName: zmxGlobalSessionName) + } + + /// The auto-start session, or nil when a custom command makes it unknowable. + var zmxSessionNameForConnection: String? { + if let custom = UserDefaults.standard.string(forKey: "zmxCustomCommand"), + !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return nil + } + let raw = multiplexerSessionNameOverride ?? Self.zmxGlobalSessionName + return Self.isEmbeddableZmxSessionName(raw) ? raw : Self.zmxDefaultSessionName + } + + /// Per-connection variant of `zmxExecCommand`. A custom "zmxCustomCommand" + /// still wins. + var zmxExecCommandForConnection: String { + if let custom = UserDefaults.standard.string(forKey: "zmxCustomCommand"), + !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return custom + } + return Self.zmxExecCommandLine(sessionName: multiplexerSessionNameOverride ?? Self.zmxGlobalSessionName) + } + /// Session name the given picker selection would attach to, for the editor /// captions. Deliberately ignores TmuxGatewaySessionStore: the editor may /// describe a profile with no live connection, and the captions already @@ -810,6 +863,13 @@ struct SSHConfig: Codable, Hashable { } let raw = pinned ?? herdrGlobalSessionName return isEmbeddableHerdrSessionName(raw) ? raw : "default" + case .zmx: + if let custom = UserDefaults.standard.string(forKey: "zmxCustomCommand"), + !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "custom" + } + let raw = pinned ?? zmxGlobalSessionName + return isEmbeddableZmxSessionName(raw) ? raw : zmxDefaultSessionName } } @@ -835,9 +895,21 @@ struct SSHConfig: Codable, Hashable { return launchCommand } + /// Whether the channel replaced the interactive shell with a command. + var hasExecTakeoverCommand: Bool { + !MuxDetachGate.hasFallbackShell( + hasRemoteCommand: !(remoteCommand?.isEmpty ?? true), + hasInitialCommandLaunch: initialLaunchCommand != nil, + tmuxAutoEnable: tmuxAutoEnable, + herdrAutoEnable: herdrAutoEnable, + zmxAutoEnable: zmxAutoEnable + ) + } + /// Returns the exec command to use, if any. /// Remote command takes precedence over profile launch command and - /// multiplexer auto-start; tmux wins over herdr if both are set. + /// multiplexer auto-start; precedence among multiplexers is tmux, then + /// herdr, then zmx. var effectiveExecCommand: String? { if let remoteCommand, !remoteCommand.isEmpty { return Self.command(remoteCommand, applying: remoteCommandPolicy) @@ -851,6 +923,9 @@ struct SSHConfig: Codable, Hashable { if herdrAutoEnable { return herdrExecCommandForConnection } + if zmxAutoEnable { + return zmxExecCommandForConnection + } return nil } @@ -875,6 +950,9 @@ struct SSHConfig: Codable, Hashable { // herdr has no control mode; the same exec line works under Mosh. return herdrExecCommandForConnection } + if zmxAutoEnable { + return zmxExecCommandForConnection + } return "$SHELL -l" } } diff --git a/rootshell/Features/SSH/Config/SSHConnectionHistory.swift b/rootshell/Features/SSH/Config/SSHConnectionHistory.swift index b1290186..a03ec026 100644 --- a/rootshell/Features/SSH/Config/SSHConnectionHistory.swift +++ b/rootshell/Features/SSH/Config/SSHConnectionHistory.swift @@ -136,6 +136,9 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor // herdr auto-enable (optional for backward compatibility) var herdrAutoEnable: Bool? + // zmx auto-enable (optional for backward compatibility) + var zmxAutoEnable: Bool? + // Launch command to run when the session is ready/started (optional for backward compatibility) var launchCommand: String? @@ -180,7 +183,7 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor init(username: String, host: String, port: Int = 22, authType: SSHAuthType, connectionProtocol: ConnectionProtocol? = nil, jumpHost: String? = nil, jumpPort: Int? = nil, jumpUsername: String? = nil, jumpAuthType: SSHAuthType? = nil, - lastUsed: Date = Date(), cachedIP: String? = nil, hssShorthand: String? = nil, agentConfig: SSHAgentConfig? = nil, gpgAgentConfig: GPGAgentConfig? = nil, portForwardConfig: PortForwardConfig? = nil, tmuxAutoEnable: Bool? = nil, tmuxAutoMode: TmuxAutoMode? = nil, herdrAutoEnable: Bool? = nil, launchCommand: String? = nil, launchCommandMode: SSHConfig.LaunchCommandMode? = nil, + lastUsed: Date = Date(), cachedIP: String? = nil, hssShorthand: String? = nil, agentConfig: SSHAgentConfig? = nil, gpgAgentConfig: GPGAgentConfig? = nil, portForwardConfig: PortForwardConfig? = nil, tmuxAutoEnable: Bool? = nil, tmuxAutoMode: TmuxAutoMode? = nil, herdrAutoEnable: Bool? = nil, zmxAutoEnable: Bool? = nil, launchCommand: String? = nil, launchCommandMode: SSHConfig.LaunchCommandMode? = nil, terminalType: String? = nil, multiplexerSessionName: String? = nil, keyResolutionHints: [String: KeyResolutionHint]? = nil) { @@ -203,6 +206,7 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor self.tmuxAutoEnable = tmuxAutoEnable self.tmuxAutoMode = tmuxAutoMode self.herdrAutoEnable = herdrAutoEnable + self.zmxAutoEnable = zmxAutoEnable self.launchCommand = launchCommand self.launchCommandMode = launchCommandMode self.terminalType = terminalType @@ -217,7 +221,7 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor connectionProtocol: ConnectionProtocol? = nil, jumpHost: String? = nil, jumpPort: Int? = nil, jumpUsername: String? = nil, jumpAuthType: SSHAuthType? = nil, lastUsed: Date = Date(), cachedIP: String? = nil, hssShorthand: String? = nil, - agentConfig: SSHAgentConfig? = nil, gpgAgentConfig: GPGAgentConfig? = nil, portForwardConfig: PortForwardConfig? = nil, tmuxAutoEnable: Bool? = nil, tmuxAutoMode: TmuxAutoMode? = nil, herdrAutoEnable: Bool? = nil, + agentConfig: SSHAgentConfig? = nil, gpgAgentConfig: GPGAgentConfig? = nil, portForwardConfig: PortForwardConfig? = nil, tmuxAutoEnable: Bool? = nil, tmuxAutoMode: TmuxAutoMode? = nil, herdrAutoEnable: Bool? = nil, zmxAutoEnable: Bool? = nil, launchCommand: String? = nil, launchCommandMode: SSHConfig.LaunchCommandMode? = nil, terminalType: String? = nil, multiplexerSessionName: String? = nil, keyResolutionHints: [String: KeyResolutionHint]? = nil, modifiedAt: Date? = nil, isDeleted: Bool = false) { self.id = id @@ -239,6 +243,7 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor self.tmuxAutoEnable = tmuxAutoEnable self.tmuxAutoMode = tmuxAutoMode self.herdrAutoEnable = herdrAutoEnable + self.zmxAutoEnable = zmxAutoEnable self.launchCommand = launchCommand self.launchCommandMode = launchCommandMode self.terminalType = terminalType @@ -253,7 +258,7 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor private enum CodingKeys: String, CodingKey { case id, username, host, port, authType, lastUsed, cachedIP case jumpHost, jumpPort, jumpUsername, jumpAuthType - case hssShorthand, agentConfig, gpgAgentConfig, portForwardConfig, tmuxAutoEnable, tmuxAutoMode, herdrAutoEnable, launchCommand, launchCommandMode + case hssShorthand, agentConfig, gpgAgentConfig, portForwardConfig, tmuxAutoEnable, tmuxAutoMode, herdrAutoEnable, zmxAutoEnable, launchCommand, launchCommandMode case connectionProtocol, keyResolutionHints case terminalType, multiplexerSessionName case modifiedAt, isDeleted @@ -284,6 +289,7 @@ struct SSHConnectionHistoryEntry: Codable, Identifiable, Hashable, SyncableRecor tmuxAutoEnable = try container.decodeIfPresent(Bool.self, forKey: .tmuxAutoEnable) tmuxAutoMode = try container.decodeIfPresent(TmuxAutoMode.self, forKey: .tmuxAutoMode) herdrAutoEnable = try container.decodeIfPresent(Bool.self, forKey: .herdrAutoEnable) + zmxAutoEnable = try container.decodeIfPresent(Bool.self, forKey: .zmxAutoEnable) launchCommand = try container.decodeIfPresent(String.self, forKey: .launchCommand) launchCommandMode = try container.decodeIfPresent(SSHConfig.LaunchCommandMode.self, forKey: .launchCommandMode) terminalType = try container.decodeIfPresent(String.self, forKey: .terminalType) @@ -465,6 +471,7 @@ class SSHConnectionHistoryManager: ObservableObject { tmuxAutoEnable: Bool? = nil, tmuxAutoMode: TmuxAutoMode? = nil, herdrAutoEnable: Bool? = nil, + zmxAutoEnable: Bool? = nil, launchCommand: String? = nil, launchCommandMode: SSHConfig.LaunchCommandMode? = nil, terminalType: String? = nil, @@ -507,6 +514,9 @@ class SSHConnectionHistoryManager: ObservableObject { if let herdrAutoEnable = herdrAutoEnable { updated.herdrAutoEnable = herdrAutoEnable } + if let zmxAutoEnable = zmxAutoEnable { + updated.zmxAutoEnable = zmxAutoEnable + } updated.launchCommand = launchCommand updated.launchCommandMode = launchCommandMode // Always write, like launchCommand: nil means the user cleared the @@ -561,6 +571,9 @@ class SSHConnectionHistoryManager: ObservableObject { if let herdrAutoEnable = herdrAutoEnable { updated.herdrAutoEnable = herdrAutoEnable } + if let zmxAutoEnable = zmxAutoEnable { + updated.zmxAutoEnable = zmxAutoEnable + } updated.launchCommand = launchCommand updated.launchCommandMode = launchCommandMode // Always write, like launchCommand: nil means the user cleared the @@ -591,6 +604,7 @@ class SSHConnectionHistoryManager: ObservableObject { tmuxAutoEnable: tmuxAutoEnable, tmuxAutoMode: tmuxAutoMode, herdrAutoEnable: herdrAutoEnable, + zmxAutoEnable: zmxAutoEnable, launchCommand: launchCommand, launchCommandMode: launchCommandMode, terminalType: terminalType, @@ -683,6 +697,30 @@ class SSHConnectionHistoryManager: ObservableObject { continue // Local is newer, keep local } // Remote is newer - update existing record (keep local UUID) + // + // The envelope-gated fields are resolved into locals first. They + // are not just for readability: inlining both ternaries into the + // initialiser below pushes it past the type-checker's budget + // ("unable to type-check this expression in reasonable time"). + let envelopeVersion = remote.syncEnvelopeVersion ?? 0 + // Gated on the envelope version, not on its presence: a build + // that predates this field still writes a valid (older) + // envelope, and its silence is a gap rather than a clear. + // Without this, reconnecting once on an older device during a + // mixed-version rollout erases the override. + let mergedMultiplexerSessionName: String? = + envelopeVersion >= HistoryExtensionPayload.multiplexerSessionNameVersion + ? remote.multiplexerSessionName + : (remote.multiplexerSessionName ?? existing.multiplexerSessionName) + // Same rule, different introducing version. Note this is NOT the + // plain-optional fallback `herdrAutoEnable` uses below: that + // field has its own CloudKit record column, so its absence is + // unambiguous, whereas an envelope member is absent both when + // the user cleared it and when the writer predates it. + let mergedZmxAutoEnable: Bool? = + envelopeVersion >= HistoryExtensionPayload.zmxAutoEnableVersion + ? remote.zmxAutoEnable + : (remote.zmxAutoEnable ?? existing.zmxAutoEnable) let updated = SSHConnectionHistoryEntry( id: existingUUID, // Keep local UUID for consistency username: remote.username, @@ -703,6 +741,7 @@ class SSHConnectionHistoryManager: ObservableObject { tmuxAutoEnable: remote.tmuxAutoEnable ?? existing.tmuxAutoEnable, tmuxAutoMode: remote.tmuxAutoMode ?? existing.tmuxAutoMode, herdrAutoEnable: remote.herdrAutoEnable ?? existing.herdrAutoEnable, + zmxAutoEnable: mergedZmxAutoEnable, launchCommand: remote.launchCommand ?? existing.launchCommand, launchCommandMode: remote.launchCommandMode ?? existing.launchCommandMode, // Remote is strictly newer here, so when it carried an @@ -713,14 +752,7 @@ class SSHConnectionHistoryManager: ObservableObject { terminalType: remote.syncCarriedExtensions ? remote.terminalType : (remote.terminalType ?? existing.terminalType), - // Gated on the envelope version, not on its presence: a - // build that predates this field still writes a valid - // (older) envelope, and its silence is a gap rather than a - // clear. Without this, reconnecting once on an older device - // during a mixed-version rollout erases the override. - multiplexerSessionName: (remote.syncEnvelopeVersion ?? 0) >= HistoryExtensionPayload.multiplexerSessionNameVersion - ? remote.multiplexerSessionName - : (remote.multiplexerSessionName ?? existing.multiplexerSessionName), + multiplexerSessionName: mergedMultiplexerSessionName, keyResolutionHints: remote.keyResolutionHints ?? existing.keyResolutionHints, modifiedAt: remote.modifiedAt, isDeleted: remote.isDeleted diff --git a/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift b/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift index 7b277d52..7a858aee 100644 --- a/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift +++ b/rootshell/Features/SSH/Discovery/SessionDiscoveryRunner.swift @@ -2,7 +2,7 @@ // SessionDiscoveryRunner.swift // rootshell // -// Combined orchestrator that discovers tmux, zellij, and herdr sessions via a single SSH exec channel. +// Combined orchestrator that discovers tmux, zellij, herdr, and zmx sessions via a single SSH exec channel. // import Foundation @@ -24,7 +24,7 @@ struct SessionDiscoveryResult: Sendable { // MARK: - Combined Discovery Command enum SessionDiscoveryCommand { - /// Builds a single shell command that discovers tmux, zellij, and herdr sessions. + /// Builds a single shell command that discovers tmux, zellij, herdr, and zmx sessions. /// Each section is wrapped in nonce-tagged markers so captured terminal content /// (which may contain marker strings if this source code is visible) cannot collide. /// Uses a single SSH exec channel for efficiency. @@ -32,6 +32,7 @@ enum SessionDiscoveryCommand { skipTmuxSessions: Bool, skipZellijSessions: Bool, skipHerdrSessions: Bool, + skipZmxSessions: Bool, discoverTmuxBindings: Bool, discoverZellijBindings: Bool, skipCaptures: Bool = false @@ -156,6 +157,60 @@ enum SessionDiscoveryCommand { ) } + if !skipZmxSessions { + // `zmx list` probes every session socket serially, so one wedged + // daemon costs a second of the budget this command shares with + // tmux, zellij and herdr. Three choices bound that: + // + // 1. ONE `zmx list`. The capture loop derives its names from that + // same output rather than a second `--short` call, which would + // pay for every wedged daemon twice. `grep -F` on a tab before + // `pid=` keeps only real rows; `cut -f1` yields the bare name. + // 2. NO tight `timeout` on the list. It emits nothing until every + // probe finishes, so a timeout that fires costs the full wall + // clock AND returns nothing -- indistinguishable to the user + // from zmx not being installed. The 5 s backstop only catches a + // genuine hang. + // 3. `timeout 2` on each `zmx history`, where truncation costs one + // preview rather than the whole result. + // + // Session names may contain spaces, `*`, `?` and quotes, so the loop + // reads them with `IFS= read -r` rather than `for s in $(...)`, + // which would split and glob them. herdr can use that idiom because + // its names are constrained; zmx's are not. + // + // `ZMX_SESSION_PREFIX=` on every call: `zmx list` reports real + // socket names but every other subcommand prepends the variable, so + // an exported prefix would silently point `history` at a different + // session. The same neutralisation is applied in + // `TerminalView+Session.attachToSession` and + // `SSHConfig.zmxExecCommandLine`. + var section = "_zt=\"\"; command -v timeout >/dev/null 2>&1 && _zt=\"timeout 2\";" + + " _zl=$(ZMX_SESSION_PREFIX= ${_zt:+timeout 5} zmx list 2>/dev/null);" + + " echo \"::SESSIONS::\";" + + " printf \"%s\\n\" \"$_zl\";" + if !skipCaptures { + // NOTE: the "\t" below is a REAL tab in the emitted shell, which + // is what makes it match a field boundary rather than the letter + // t. Do not "tidy" it into an escape sequence. + section += " echo \"::CAPTURES::\";" + + " printf \"%s\\n\" \"$_zl\" | grep -F \"\tpid=\"" + + " | sed \"s/^[^=]*=//\" | cut -f1 | head -n 8 |" + + " while IFS= read -r s; do" + + " printf \"::CAPTURE:%s::\\n\" \"$s\";" + + " ZMX_SESSION_PREFIX= $_zt zmx history \"$s\" --vt 2>/dev/null | tail -n 200 || true;" + + " printf \"\\n\";" + + " done;" + } + parts.append( + "echo \"::ZMX_START_\(nonce)::\";" + + " if command -v zmx >/dev/null 2>&1; then" + + " \(section)" + + " fi;" + + " echo \"::ZMX_END_\(nonce)::\"" + ) + } + let body = parts.joined(separator: " ; ") return ("sh -lc '\(SSHConfig.remoteExecPathPrefix)\(body)'", nonce) } @@ -172,6 +227,7 @@ extension SessionDiscoveryRunner { skipTmuxSessions: Bool = false, skipZellijSessions: Bool = false, skipHerdrSessions: Bool = false, + skipZmxSessions: Bool = false, discoverTmuxBindings: Bool = true, discoverZellijBindings: Bool = true ) async throws -> SessionDiscoveryResult { @@ -185,6 +241,7 @@ extension SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings ) @@ -208,6 +265,7 @@ extension SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings, nonce: nonce @@ -225,13 +283,14 @@ enum SessionDiscoveryParser { category: "SessionDiscoveryParser" ) - /// Parse combined output, extracting tmux, zellij, and herdr sections independently. + /// Parse combined output, extracting tmux, zellij, herdr, and zmx sections independently. /// Nonce-tagged markers ensure captured terminal content cannot cause false matches. static func parse( output: String, skipTmuxSessions: Bool, skipZellijSessions: Bool, skipHerdrSessions: Bool, + skipZmxSessions: Bool, discoverTmuxBindings: Bool, discoverZellijBindings: Bool, nonce: String @@ -239,6 +298,7 @@ enum SessionDiscoveryParser { var tmuxSessions: [TmuxSessionInfo] = [] var zellijSessions: [ZellijSessionInfo] = [] var herdrSessions: [HerdrSessionInfo] = [] + var zmxSessions: [ZmxSessionInfo] = [] var swipeBindings = MultiplexerSwipeBindings() // Extract and parse tmux section @@ -275,13 +335,28 @@ enum SessionDiscoveryParser { herdrSessions = HerdrDiscoveryParser.parse(output: herdrOutput) } - return merge(tmux: tmuxSessions, zellij: zellijSessions, herdr: herdrSessions, swipeBindings: swipeBindings) + // Extract and parse zmx section + if !skipZmxSessions, + let zmxStart = output.range(of: "::ZMX_START_\(nonce)::"), + let zmxEnd = output.range(of: "::ZMX_END_\(nonce)::") { + let zmxOutput = String(output[zmxStart.upperBound.. SessionDiscoveryResult { var types = Set() @@ -302,6 +377,11 @@ enum SessionDiscoveryParser { sessions.append(contentsOf: herdr.map { MultiplexerSession.from(herdr: $0) }) } + if !zmx.isEmpty { + types.insert(.zmx) + sessions.append(contentsOf: zmx.map { MultiplexerSession.from(zmx: $0) }) + } + let rawOrder = UserDefaults.standard.string(forKey: SessionDiscoverySortOrder.storageKey) let order = rawOrder.flatMap(SessionDiscoverySortOrder.init(rawValue:)) ?? .attachedFirst sessions.sort(by: order.compare) @@ -338,6 +418,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: Bool = false, skipZellijSessions: Bool = false, skipHerdrSessions: Bool = false, + skipZmxSessions: Bool = false, discoverTmuxBindings: Bool = true, discoverZellijBindings: Bool = true ) async throws -> SessionDiscoveryResult { @@ -349,6 +430,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings ) @@ -364,6 +446,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: Bool = false, skipZellijSessions: Bool = false, skipHerdrSessions: Bool = false, + skipZmxSessions: Bool = false, discoverTmuxBindings: Bool = true, discoverZellijBindings: Bool = true, onKeyboardInteractiveChallenge: ((KeyboardInteractiveChallenge) async -> [String]?)? = nil @@ -390,6 +473,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings ) @@ -412,6 +496,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: Bool, skipZellijSessions: Bool, skipHerdrSessions: Bool, + skipZmxSessions: Bool, discoverTmuxBindings: Bool, discoverZellijBindings: Bool ) async throws -> SessionDiscoveryResult { @@ -421,6 +506,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings, skipCaptures: false @@ -440,6 +526,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: false, discoverZellijBindings: false, skipCaptures: true @@ -460,6 +547,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: Bool, skipZellijSessions: Bool, skipHerdrSessions: Bool, + skipZmxSessions: Bool, discoverTmuxBindings: Bool, discoverZellijBindings: Bool, skipCaptures: Bool @@ -468,6 +556,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings, skipCaptures: skipCaptures @@ -497,6 +586,7 @@ enum SessionDiscoveryRunner { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings, nonce: nonce diff --git a/rootshell/Features/SSH/Discovery/ZmxDiscoveryParser.swift b/rootshell/Features/SSH/Discovery/ZmxDiscoveryParser.swift new file mode 100644 index 00000000..9fc76dd3 --- /dev/null +++ b/rootshell/Features/SSH/Discovery/ZmxDiscoveryParser.swift @@ -0,0 +1,248 @@ +// +// ZmxDiscoveryParser.swift +// rootshell +// +// Data model and parser for zmx session detection on SSH hosts. zmx is a +// session attach/detach multiplexer: one session is exactly one PTY, with no +// windows, tabs, splits or control protocol. `zmx list` is its only +// enumeration primitive, and `zmx history --vt` provides the preview. +// + +import Foundation +import os + +// MARK: - Data Model + +struct ZmxSessionInfo: Identifiable, Equatable, Sendable { + let name: String + + /// Attached interactive clients, excluding the probing connection. + let clientCount: Int? + + let createdAt: Date? + + /// Decoded `cwd=` from current zmx, or bare `start_dir=` from v0.7.0. + let cwd: String? + + /// Explicit command supplied to `zmx attach`, when present. + let command: String? + + /// Labels from `zmx set` or `zmx attach --labels`. + let labels: [String: String] + + var capturedContent: String? + + var id: String { name } + + var isAttached: Bool { (clientCount ?? 0) > 0 } + + /// Relative creation time, e.g. "Created 2 hr. ago", echoing the shape + /// zellij rows already use in the picker. Empty when zmx gave no timestamp. + /// + /// zellij's equivalent is scraped verbatim out of zellij's own output + /// (`[Created 2h ago]`), so there is no existing formatter to share; zmx + /// reports a unix timestamp and this formats it. + var createdAgo: String { + guard let createdAt else { return "" } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + let relative = formatter.localizedString(for: createdAt, relativeTo: Date()) + return String( + format: String(localized: "Created %@", comment: "zmx session age, e.g. 'Created 2 hr. ago'"), + relative + ) + } +} + +// MARK: - Parser + +enum ZmxDiscoveryParser { + + private nonisolated static let logger = Logger( + subsystem: "com.rootshell", + category: "ZmxDiscoveryParser" + ) + + /// Built-in field order; later duplicate keys are user labels. + private nonisolated static let builtInOrder = [ + "pid", "clients", "created", "cwd", "cmd", "ended", "exit_code", + ] + + /// v0.7.0's name for the `cwd` slot. Same position, different shape. + private nonisolated static let legacyCwdKey = "start_dir" + + nonisolated static func parse(output: String) -> [ZmxSessionInfo] { + guard let sessionsMarker = lineMarkerRange("::SESSIONS::", in: output) else { return [] } + let remainder = output[sessionsMarker.upperBound...] + let capturesMarker = lineMarkerRange( + "::CAPTURES::", + in: output, + range: remainder.startIndex.. ZmxSessionInfo? { + // Current-session rows use an arrow prefix; other rows use spaces. + var line = rawLine + if line.hasPrefix("→ ") { + line = String(line.dropFirst(2)) + } + // Also drop a trailing CR, so CRLF-terminated output parses. + if line.hasSuffix("\r") { + line = String(line.dropLast()) + } + line = line.trimmingCharacters(in: .whitespaces) + guard !line.isEmpty else { return nil } + + // Commands may contain `=`, so split each field only once. + let fields = line.components(separatedBy: "\t").map(splitField) + + guard let first = fields.first, first.key == "name", !first.value.isEmpty else { + return nil + } + + // Live rows always begin with name then pid. Requiring that shape also + // rejects names containing tabs/newlines, which this format cannot + // represent without ambiguity. + guard fields.count > 1, fields[1].key == "pid" else { return nil } + + var values: [String: String] = [:] + var labels: [String: String] = [:] + var cursor = 0 + var inLabels = false + // Which spelling filled the cwd slot; the two shapes decode differently. + var cwdIsURI = false + + for field in fields.dropFirst() { + if !inLabels { + // `start_dir` (v0.7.0) occupies the same slot as `cwd` (HEAD). + let normalized = field.key == legacyCwdKey ? "cwd" : field.key + if let offset = builtInOrder[cursor...].firstIndex(of: normalized) { + values[normalized] = field.value + if normalized == "cwd" { + cwdIsURI = field.key == "cwd" + } + cursor = offset + 1 + continue + } + // First out-of-sequence key: everything from here on is a label. + inLabels = true + } + if !field.key.isEmpty { + labels[field.key] = field.value + } + } + + let cwd = decodeWorkingDirectory(values["cwd"], isURI: cwdIsURI) + + return ZmxSessionInfo( + name: first.value, + clientCount: values["clients"].flatMap(Int.init), + createdAt: values["created"].flatMap(Double.init).map(Date.init(timeIntervalSince1970:)), + cwd: cwd, + command: values["cmd"].flatMap { $0.isEmpty ? nil : $0 }, + labels: labels, + capturedContent: nil + ) + } + + /// Splits `key=value` on the first `=` only. A field with no `=` becomes a + /// key with an empty value, which the callers treat as uninteresting. + private nonisolated static func splitField(_ field: String) -> (key: String, value: String) { + guard let separator = field.firstIndex(of: "=") else { + return (field, "") + } + return ( + String(field[field.startIndex.. String? { + guard let value, !value.isEmpty else { return nil } + return isURI ? WorkingDirectoryURI.path(value) : value + } + + /// Finds a marker only when it occupies a complete line, so command output + /// containing the same text cannot split the protocol payload. + private nonisolated static func lineMarkerRange( + _ marker: String, + in text: String, + range: Range? = nil + ) -> Range? { + let searchRange = range ?? text.startIndex..::` chunks of raw ANSI content. + private nonisolated static func parseCaptures(block: String) -> [String: String] { + guard !block.isEmpty else { return [:] } + var capturesBySession: [String: String] = [:] + for chunk in block.components(separatedBy: "::CAPTURE:") { + guard let markerEnd = chunk.range(of: "::\n") ?? chunk.range(of: "::\r\n") else { continue } + let sessionName = String(chunk[chunk.startIndex.. String { + guard let lastClear = content.range(of: "\u{1B}[2J", options: .backwards) else { + return content + } + return String(content[lastClear.upperBound...]) + } +} diff --git a/rootshell/Features/SSH/Session/MultiplexerSession.swift b/rootshell/Features/SSH/Session/MultiplexerSession.swift index f83cf403..d69b72d0 100644 --- a/rootshell/Features/SSH/Session/MultiplexerSession.swift +++ b/rootshell/Features/SSH/Session/MultiplexerSession.swift @@ -2,7 +2,7 @@ // MultiplexerSession.swift // rootshell // -// Unified model for terminal multiplexer sessions (tmux, zellij, herdr). +// Unified model for terminal multiplexer sessions (tmux, zellij, herdr, zmx). // import Foundation @@ -13,6 +13,26 @@ enum MultiplexerType: String, Sendable, Equatable, Hashable { case tmux case zellij case herdr + case zmx + + /// Whether the multiplexer, rather than its inner program, owns the screen. + var ownsAlternateScreen: Bool { + switch self { + case .tmux, .zellij, .herdr: return true + case .zmx: return false + } + } + + /// SF Symbol representing this multiplexer. + /// + var iconName: String { + switch self { + case .tmux: return "rectangle.split.2x1" + case .zellij: return "rectangle.split.3x1" + case .herdr: return "square.grid.2x2" + case .zmx: return "rectangle" + } + } } // MARK: - Unified Session Model @@ -25,6 +45,8 @@ struct MultiplexerSession: Identifiable, Equatable, Sendable { let detail: String /// Secondary info: active command+path for tmux, nil for zellij let subtitle: String? + /// Absolute working directory reported by the session. + let workingDirectory: String? /// ANSI-captured terminal content for preview rendering var capturedContent: String? /// Whether this is an exited zellij session that can be resurrected @@ -48,17 +70,21 @@ extension MultiplexerSession { isAttached: session.isAttached, detail: detail, subtitle: subtitle.isEmpty ? nil : subtitle, + // tmux panes are bound as a raw multiplexer, and `noteProject` + // refuses outright inside one, so a directory here would be + // carried and never used. + workingDirectory: nil, capturedContent: session.capturedContent, isExited: false ) } + /// Collapses `/home/` and `/Users/` to `~`. private static func shortenPath(_ path: String) -> String { - if path.hasPrefix("/home/") { - return "~" + String(path.dropFirst(5 + path.dropFirst(6).prefix(while: { $0 != "/" }).count)) - } - if path.hasPrefix("/Users/") { - return "~" + String(path.dropFirst(6 + path.dropFirst(7).prefix(while: { $0 != "/" }).count)) + for root in ["/home/", "/Users/"] where path.hasPrefix(root) { + let afterRoot = path.dropFirst(root.count) + let username = afterRoot.prefix(while: { $0 != "/" }) + return "~" + afterRoot.dropFirst(username.count) } return path } @@ -74,12 +100,40 @@ extension MultiplexerSession { isAttached: session.isAttached, detail: session.createdAgo, subtitle: nil, + workingDirectory: nil, capturedContent: session.capturedContent, isExited: session.isExited ) } } +// MARK: - Factory: from ZmxSessionInfo + +extension MultiplexerSession { + static func from(zmx session: ZmxSessionInfo) -> MultiplexerSession { + // zmx has no windows to count; show age when nobody is attached. + let clientCount = session.clientCount ?? 0 + let detail = clientCount > 0 + ? String(localized: "\(clientCount) clients", comment: "zmx attached client count") + : session.createdAgo + + let subtitle = [session.labels["project"], session.cwd.map { shortenPath($0) }] + .compactMap { $0 } + .joined(separator: " · ") + + return MultiplexerSession( + type: .zmx, + name: session.name, + isAttached: session.isAttached, + detail: detail, + subtitle: subtitle.isEmpty ? nil : subtitle, + workingDirectory: session.cwd, + capturedContent: session.capturedContent, + isExited: false + ) + } +} + // MARK: - Factory: from HerdrSessionInfo extension MultiplexerSession { @@ -92,6 +146,8 @@ extension MultiplexerSession { isAttached: false, detail: session.detailText, subtitle: session.agentSubtitle, + // Raw-multiplexer bound like tmux; see the note there. + workingDirectory: nil, capturedContent: session.capturedContent, isExited: !session.isRunning ) diff --git a/rootshell/Features/SSH/Views/SSHConnectionView.swift b/rootshell/Features/SSH/Views/SSHConnectionView.swift index aeae1888..623b6697 100644 --- a/rootshell/Features/SSH/Views/SSHConnectionView.swift +++ b/rootshell/Features/SSH/Views/SSHConnectionView.swift @@ -90,6 +90,9 @@ struct SSHConnectionView: View { // herdr auto-attach (mutually exclusive with enableTmux via the picker) @State private var enableHerdr: Bool = false + // zmx auto-attach (mutually exclusive with the others via the picker) + @State private var enableZmx: Bool = false + // Launch command @State private var launchCommand: String = "" @State private var launchCommandMode: SSHConfig.LaunchCommandMode = .afterConnect @@ -498,6 +501,11 @@ struct SSHConnectionView: View { isTerminalOptionsExpanded = true } } + .onChange(of: enableZmx) { _, enabled in + if enabled { + isTerminalOptionsExpanded = true + } + } .onChange(of: launchCommand) { _, newValue in if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { isTerminalOptionsExpanded = true @@ -1089,15 +1097,17 @@ struct SSHConnectionView: View { connectionProtocol == .mosh ? .regular : tmuxAutoMode } - /// Bridges the stored `(enableTmux, tmuxAutoMode, enableHerdr)` fields to a + /// Bridges the stored `(enableTmux, tmuxAutoMode, enableHerdr, enableZmx)` fields to a /// single Picker. Control mode can't run over Mosh, so it's presented as /// regular there. private var tmuxLaunchSelection: Binding { Binding( - get: { TmuxLaunchSelection(tmuxEnabled: enableTmux, mode: effectiveTmuxAutoMode, herdrEnabled: enableHerdr) }, + get: { TmuxLaunchSelection(tmuxEnabled: enableTmux, mode: effectiveTmuxAutoMode, + herdrEnabled: enableHerdr, zmxEnabled: enableZmx) }, set: { sel in enableTmux = sel.tmuxEnabled enableHerdr = sel.herdrEnabled + enableZmx = sel.zmxEnabled if sel.tmuxEnabled { tmuxAutoMode = sel.mode } } ) @@ -1112,6 +1122,7 @@ struct SSHConnectionView: View { Text("tmux -CC (control)").tag(TmuxLaunchSelection.control) } Text("herdr").tag(TmuxLaunchSelection.herdr) + Text("zmx").tag(TmuxLaunchSelection.zmx) } .pickerStyle(.menu) @@ -1127,6 +1138,10 @@ struct SSHConnectionView: View { Text("Attach to or create herdr session \"\(multiplexerCaptionSessionName)\" on connect") .font(.caption) .foregroundColor(.secondary) + } else if enableZmx { + Text("Attach to or create zmx session \"\(multiplexerCaptionSessionName)\" on connect") + .font(.caption) + .foregroundColor(.secondary) } VStack(alignment: .leading, spacing: 4) { @@ -1551,6 +1566,16 @@ struct SSHConnectionView: View { return String(localized: "herdr attaches automatically") } + if enableZmx && !trimmedCommand.isEmpty { + return launchCommandMode == .initialCommandWithPTY + ? String(localized: "Initial command with PTY") + : String(localized: "zmx enabled, launch command configured") + } + + if enableZmx { + return String(localized: "zmx attaches automatically") + } + if !trimmedCommand.isEmpty { return launchCommandMode == .initialCommandWithPTY ? String(localized: "Initial command with PTY") @@ -1666,6 +1691,7 @@ struct SSHConnectionView: View { enableTmux = config.tmuxAutoEnable tmuxAutoMode = config.tmuxAutoMode enableHerdr = config.herdrAutoEnable + enableZmx = config.zmxAutoEnable launchCommand = config.launchCommand ?? "" launchCommandMode = config.launchCommandMode restoreCarriedOverrides(terminalType: config.terminalType, @@ -1881,6 +1907,7 @@ struct SSHConnectionView: View { tmuxAutoEnable: enableTmux ? true : nil, tmuxAutoMode: enableTmux ? effectiveTmuxAutoMode : nil, herdrAutoEnable: enableHerdr ? true : nil, + zmxAutoEnable: enableZmx ? true : nil, launchCommand: launchCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : launchCommand.trimmingCharacters(in: .whitespacesAndNewlines), launchCommandMode: launchCommandMode, terminalType: terminalTypeOverride( @@ -2071,6 +2098,7 @@ struct SSHConnectionView: View { ) config.fallbackKeyIDs = fallbackKeyIDs config.herdrAutoEnable = enableHerdr + config.zmxAutoEnable = enableZmx // Apply the GPG agent config after the SSHConfig is built — // the convenience initializers above don't carry it. @@ -2554,6 +2582,7 @@ struct SSHConnectionView: View { enableTmux = entry.tmuxAutoEnable ?? false tmuxAutoMode = entry.tmuxAutoMode ?? .regular enableHerdr = entry.herdrAutoEnable ?? false + enableZmx = entry.zmxAutoEnable ?? false // Restore launch command if present launchCommand = entry.launchCommand ?? "" @@ -2685,6 +2714,7 @@ struct SSHConnectionView: View { enableTmux = config.tmuxAutoEnable tmuxAutoMode = config.tmuxAutoMode enableHerdr = config.herdrAutoEnable + enableZmx = config.zmxAutoEnable // Set launch command launchCommand = config.launchCommand ?? "" diff --git a/rootshell/Features/Tmux/Settings/MultiplexerSettingsView.swift b/rootshell/Features/Tmux/Settings/MultiplexerSettingsView.swift index 5b2f1a3a..cebfe41a 100644 --- a/rootshell/Features/Tmux/Settings/MultiplexerSettingsView.swift +++ b/rootshell/Features/Tmux/Settings/MultiplexerSettingsView.swift @@ -4,6 +4,7 @@ struct MultiplexerSettingsView: View { @AppStorage("tmuxSessionDiscoveryEnabled") private var tmuxDiscoveryEnabled = true @AppStorage("zellijSessionDiscoveryEnabled") private var zellijDiscoveryEnabled = true @AppStorage("herdrSessionDiscoveryEnabled") private var herdrDiscoveryEnabled = true + @AppStorage("zmxSessionDiscoveryEnabled") private var zmxDiscoveryEnabled = true @AppStorage("localSessionDiscoveryEnabled") private var localDiscoveryEnabled = true @AppStorage(SessionDiscoverySortOrder.storageKey) private var sortOrder = SessionDiscoverySortOrder.attachedFirst.rawValue @AppStorage(TabExposeSettings.multiplexerEnabledKey) private var exposeMultiplexerEnabled = true @@ -11,6 +12,8 @@ struct MultiplexerSettingsView: View { @AppStorage("tmuxCustomCommand") private var customCommand = "" @AppStorage("herdrSessionName") private var herdrSessionName = "" @AppStorage("herdrCustomCommand") private var herdrCustomCommand = "" + @AppStorage("zmxSessionName") private var zmxSessionName = "" + @AppStorage("zmxCustomCommand") private var zmxCustomCommand = "" @AppStorage(TmuxController.autoHideGatewayOnAttachDefaultsKey) private var autoHideGatewayOnAttach = false @AppStorage(TmuxTabCloseAction.storageKey) @@ -38,6 +41,16 @@ struct MultiplexerSettingsView: View { return name.isEmpty ? "default" : name } + private var zmxAutoStartCommandSummary: String { + if !zmxCustomCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "Custom" + } + let name = zmxSessionName.trimmingCharacters(in: .whitespacesAndNewlines) + // Unlike herdr there is no unnamed default session to fall back to, so + // the placeholder is a real session name. + return name.isEmpty ? SSHConfig.zmxDefaultSessionName : name + } + private var discoveryFooterText: String { let base = "Checks for active sessions after an SSH connection. Skipped for connections with multiplexer auto-start enabled." #if targetEnvironment(macCatalyst) @@ -52,7 +65,7 @@ struct MultiplexerSettingsView: View { Section { Toggle(isOn: $tmuxDiscoveryEnabled) { HStack(spacing: 12) { - SettingsIcon(systemName: "rectangle.split.2x1") + SettingsIcon(systemName: MultiplexerType.tmux.iconName) Text("Discover tmux Sessions") } } @@ -60,7 +73,7 @@ struct MultiplexerSettingsView: View { Toggle(isOn: $zellijDiscoveryEnabled) { HStack(spacing: 12) { - SettingsIcon(systemName: "rectangle.split.3x1") + SettingsIcon(systemName: MultiplexerType.zellij.iconName) Text("Discover zellij Sessions") } } @@ -68,12 +81,20 @@ struct MultiplexerSettingsView: View { Toggle(isOn: $herdrDiscoveryEnabled) { HStack(spacing: 12) { - SettingsIcon(systemName: "square.grid.2x2") + SettingsIcon(systemName: MultiplexerType.herdr.iconName) Text("Discover herdr Sessions") } } .themedRow() + Toggle(isOn: $zmxDiscoveryEnabled) { + HStack(spacing: 12) { + SettingsIcon(systemName: MultiplexerType.zmx.iconName) + Text("Discover zmx Sessions") + } + } + .themedRow() + #if targetEnvironment(macCatalyst) Toggle(isOn: $localDiscoveryEnabled) { HStack(spacing: 12) { @@ -197,11 +218,31 @@ struct MultiplexerSettingsView: View { Text("The herdr command used when auto-start is enabled on a connection.") } + Section { + NavigationLink { + ZmxAutoStartCommandView() + } label: { + HStack(spacing: 12) { + SettingsIcon(systemName: "play.rectangle") + Text("Auto-Start Command") + Spacer() + Text(zmxAutoStartCommandSummary) + .foregroundColor(.secondary) + .font(.subheadline) + } + } + .themedRow() + } header: { + Text("zmx Auto-Start") + } footer: { + Text("The zmx command used when auto-start is enabled on a connection.") + } + Section { NavigationLink { TmuxGuideView() } label: { - Label("tmux Tips", systemImage: "questionmark.circle") + Label("Multiplexer Tips", systemImage: "questionmark.circle") } .themedRow() } diff --git a/rootshell/Features/Tmux/Settings/ZmxAutoStartCommandView.swift b/rootshell/Features/Tmux/Settings/ZmxAutoStartCommandView.swift new file mode 100644 index 00000000..7606bbe2 --- /dev/null +++ b/rootshell/Features/Tmux/Settings/ZmxAutoStartCommandView.swift @@ -0,0 +1,101 @@ +// +// ZmxAutoStartCommandView.swift +// rootshell +// + +import SwiftUI + +struct ZmxAutoStartCommandView: View { + @AppStorage("zmxSessionName") private var sessionName = "" + @AppStorage("zmxCustomCommand") private var customCommand = "" + @State private var copied = false + @FocusState private var isEditorFocused: Bool + + private var hasCustomCommand: Bool { + !customCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + Form { + Section { + // Placeholder is a real session name, not "default": zmx has no + // unnamed default session, so an empty field means "main". + TextField(SSHConfig.zmxDefaultSessionName, text: $sessionName) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .disabled(hasCustomCommand) + .foregroundStyle(hasCustomCommand ? .secondary : .primary) + .themedRow() + } header: { + Text("Session Name") + } footer: { + if hasCustomCommand { + Text("Ignored when a custom command is set.") + } else { + Text("The zmx session name used by auto-start. Defaults to \"main\" if empty.") + } + } + + Section { + TextEditor(text: $customCommand) + .font(.system(.body, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .focused($isEditorFocused) + .frame(minHeight: 80) + .themedRow() + + if hasCustomCommand { + Button("Clear Custom Command", role: .destructive) { + customCommand = "" + } + .themedRow() + } + } header: { + Text("Custom Command") + } footer: { + Text("Overrides the entire zmx command, including the session name. Leave empty to use the default.") + } + + Section { + HStack(alignment: .top) { + Text(SSHConfig.zmxExecCommand) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + UIPasteboard.general.string = SSHConfig.zmxExecCommand + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + copied = false + } + } label: { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .foregroundStyle(copied ? .green : .accentColor) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.borderless) + } + .themedRow() + } header: { + Text("Effective Command") + } footer: { + Text("This command runs when zmx auto-start is enabled on a connection. zmx attaches to the session if it is running, or starts it.") + } + } + .themedList() + #if !os(visionOS) + .scrollDismissesKeyboard(.interactively) + #endif + .navigationTitle("Auto-Start Command") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationView { + ZmxAutoStartCommandView() + } +} diff --git a/rootshell/UI/Overlays/SessionPickerOverlay.swift b/rootshell/UI/Overlays/SessionPickerOverlay.swift index a2002187..55db532f 100644 --- a/rootshell/UI/Overlays/SessionPickerOverlay.swift +++ b/rootshell/UI/Overlays/SessionPickerOverlay.swift @@ -2,7 +2,7 @@ // SessionPickerOverlay.swift // rootshell // -// Overlay for selecting an active multiplexer session (tmux/zellij/herdr) after SSH connection. +// Overlay for selecting an active multiplexer session (tmux/zellij/herdr/zmx) after SSH connection. // import SwiftUI @@ -29,13 +29,12 @@ struct SessionPickerOverlay: View { } private var headerIcon: String { - if sessionTypes.count == 1, sessionTypes.contains(.zellij) { - return "rectangle.split.2x1" - } - if sessionTypes.count == 1, sessionTypes.contains(.herdr) { - return "square.grid.2x2" + // Single-type pickers show that multiplexer's own icon; mixed pickers + // fall back to tmux's, which reads as a generic "panes" glyph. + if sessionTypes.count == 1, let type = sessionTypes.first { + return type.iconName } - return "rectangle.split.3x1" + return MultiplexerType.tmux.iconName } private var isMixed: Bool { sessionTypes.count > 1 } diff --git a/rootshell/UI/Settings/Guides/TmuxGuideView.swift b/rootshell/UI/Settings/Guides/TmuxGuideView.swift index f8a81278..8aa0ab55 100644 --- a/rootshell/UI/Settings/Guides/TmuxGuideView.swift +++ b/rootshell/UI/Settings/Guides/TmuxGuideView.swift @@ -3,8 +3,9 @@ // rootshell // // Explains tmux -CC control mode (gateway tab, auto-hide, tab shortcuts) -// and shows the recommended ~/.tmux.conf lines, so the Multiplexers screen -// can keep its footers short. +// and zmx's detach key and attach-or-create behaviour, and shows the +// recommended ~/.tmux.conf lines, so the Multiplexers screen can keep its +// footers short. // import SwiftUI @@ -52,6 +53,37 @@ struct TmuxGuideView: View { Text("Control Mode") } + // MARK: - zmx + Section { + VStack(alignment: .leading, spacing: 12) { + guideRow( + icon: "arrow.right.square", + title: "Detaching", + description: "Press ctrl+\\ to detach from a zmx session and return to the shell. Set ZMX_NO_DETACH_KEY=1 on the host to disable that key if it conflicts with something you use." + ) + + Divider() + + guideRow( + icon: "plus.square.on.square", + title: "Attach or Create", + description: "zmx attach joins the session if it exists and creates it otherwise, so auto-start never fails on a fresh host. There is no unnamed default session, so a name is always used — \"main\" unless you set another." + ) + + Divider() + + guideRow( + icon: "arrow.up.left.and.arrow.down.right", + title: "Resizing", + description: "A zmx session has one size shared by every attached client, and the last client to type sets it. Attaching from rootshell will reflow the session for anyone else attached as soon as you type. This is how zmx works, not a rootshell limitation." + ) + } + .padding(.vertical, 4) + .themedRow() + } header: { + Text("zmx") + } + // MARK: - Recommended Configuration Section { Text("Add these lines to enable mouse support and pass window titles through to rootshell tab titles.") @@ -87,7 +119,7 @@ struct TmuxGuideView: View { } } .themedList() - .navigationTitle("tmux Tips") + .navigationTitle("Multiplexer Tips") .navigationBarTitleDisplayMode(.inline) } diff --git a/rootshell/UI/Shell/MainView+TabManagement.swift b/rootshell/UI/Shell/MainView+TabManagement.swift index 8193e2d6..791edf53 100644 --- a/rootshell/UI/Shell/MainView+TabManagement.swift +++ b/rootshell/UI/Shell/MainView+TabManagement.swift @@ -426,8 +426,10 @@ extension MainView { func createLocalShellTabInternal() { // Get CWD from focused terminal (if any) to inherit working directory let inheritedCwd: String? - if selectedTabIndex >= 0 && selectedTabIndex < terminals.count { - inheritedCwd = terminals[selectedTabIndex].focusedTerminal?.pwd + if selectedTabIndex >= 0 && selectedTabIndex < terminals.count, + let focusedTerminal = terminals[selectedTabIndex].focusedTerminal, + case .local = focusedTerminal.connectionConfig { + inheritedCwd = focusedTerminal.pwd } else { inheritedCwd = nil } @@ -498,7 +500,13 @@ extension MainView { // Local splits inherit the working directory from the resolved // split target (not the tab's focused terminal at call time). // Non-terminal split targets have no cwd to inherit. - configFor: { .local(workingDirectory: $0.asTerminal?.pwd) }, + configFor: { + guard let terminal = $0.asTerminal, + case .local = terminal.connectionConfig else { + return .local(workingDirectory: nil) + } + return .local(workingDirectory: terminal.pwd) + }, fallbackToTab: { self.createLocalShellTabInternal() } ) } diff --git a/rootshell/UI/Tabs/MuxTabPreviewView.swift b/rootshell/UI/Tabs/MuxTabPreviewView.swift index 4d9bc66c..99400e76 100644 --- a/rootshell/UI/Tabs/MuxTabPreviewView.swift +++ b/rootshell/UI/Tabs/MuxTabPreviewView.swift @@ -36,6 +36,14 @@ final class MuxTabPreviewView: UIView { /// holds it; grown when the surface reports a grid that is too small. var slackColumns: CGFloat = 2 var slackRows: CGFloat = 2 + /// A reported grid must stay unchanged until Ghostty's terminal + /// mailbox has had time to apply the resize. `ghostty_surface_set_size` + /// publishes the new grid immediately, but the mailbox lags behind; + /// writing sooner can parse a 144-column frame at the old (roughly + /// half-width) grid, wrapping and scrolling it before the new size + /// lands. This matches `TmuxPreviewContainer`'s established delay. + var settledGrid: String? + var readyAfter: CFTimeInterval = 0 } private var previews: [String: PanePreview] = [:] @@ -119,8 +127,10 @@ final class MuxTabPreviewView: UIView { width: (CGFloat(rect.width) + preview.slackColumns) * cell.width + padX * 2, height: (CGFloat(rect.height) + preview.slackRows) * cell.height + padY * 2 ) - if abs(preview.surfaceSize.width - virtual.width) > 0.5 - || abs(preview.surfaceSize.height - virtual.height) > 0.5 { + let resized = abs(preview.surfaceSize.width - virtual.width) > 0.5 + || abs(preview.surfaceSize.height - virtual.height) > 0.5 + let isZmx = feed?.type == .zmx + if resized { preview.surfaceSize = virtual surface.bounds = CGRect(origin: .zero, size: virtual) surface.setNeedsLayout() @@ -128,6 +138,10 @@ final class MuxTabPreviewView: UIView { // The old frame was laid out for the old grid: draw it again. preview.writtenRevision = nil preview.writtenGrid = nil + if isZmx { + preview.settledGrid = nil + preview.readyAfter = CACurrentMediaTime() + 0.15 + } } // Map the pane's own cells onto the cell, ignoring slack AND the // padding: the grid starts at (padX, padY), so scaling from the @@ -136,14 +150,31 @@ final class MuxTabPreviewView: UIView { // and any sub-cell remainder sits on the trailing edges. let sx = frame.width / max(CGFloat(rect.width) * cell.width, 1) let sy = frame.height / max(CGFloat(rect.height) * cell.height, 1) - surface.transform = CGAffineTransform(translationX: -padX * sx, y: -padY * sy) - .scaledBy(x: sx, y: sy) + if isZmx { + // Separate zmx sessions may have different aspect ratios. + let fit = min(sx, sy) + let insetX = (frame.width - CGFloat(rect.width) * cell.width * fit) / 2 + let insetY = (frame.height - CGFloat(rect.height) * cell.height * fit) / 2 + surface.transform = CGAffineTransform( + translationX: insetX - padX * fit, y: insetY - padY * fit + ).scaledBy(x: fit, y: fit) + } else { + surface.transform = CGAffineTransform(translationX: -padX * sx, y: -padY * sy) + .scaledBy(x: sx, y: sy) + } // Write only into a grid that can hold the capture unwrapped. let grid = surface.gridSize let fits = grid.map { $0.columns >= rect.width && $0.rows >= rect.height } ?? false let gridKey = grid.map { "\($0.columns)x\($0.rows)" } - if fits, let latest = feed?.frame(for: pane.id), + let gridIsSettled = !isZmx || (!resized + && CACurrentMediaTime() >= preview.readyAfter + && gridKey != nil + && preview.settledGrid == gridKey) + if isZmx, !resized, preview.settledGrid != gridKey { + preview.settledGrid = gridKey + } + if gridIsSettled, fits, let latest = feed?.frame(for: pane.id), latest.revision != preview.writtenRevision || gridKey != preview.writtenGrid { surface.writeFrame(latest.ansi, cursor: latest.cursor.map { ($0.x, $0.y, $0.visible) }) preview.writtenRevision = latest.revision diff --git a/rootshell/UI/Tabs/TabExposeTrayView.swift b/rootshell/UI/Tabs/TabExposeTrayView.swift index ca784b70..466540ea 100644 --- a/rootshell/UI/Tabs/TabExposeTrayView.swift +++ b/rootshell/UI/Tabs/TabExposeTrayView.swift @@ -46,6 +46,7 @@ final class TabExposeTrayView: UIScrollView { header.addSubview(headerIcon) header.addSubview(headerLabel) header.isUserInteractionEnabled = false + header.accessibilityTraits = .header addSubview(header) } @@ -65,7 +66,8 @@ final class TabExposeTrayView: UIScrollView { muxFeed: MultiplexerExposeFeed? = nil, selectedID: UUID?, highlightedID: UUID?, - appearance: TabExposeView.Appearance + appearance: TabExposeView.Appearance, + onSelect: ((UUID) -> Void)? = nil ) -> [TabExposeCellView] { self.appearance = appearance self.muxFeed = muxFeed @@ -91,6 +93,7 @@ final class TabExposeTrayView: UIScrollView { } cell.isCurrent = id == selectedID cell.isHighlighted = id == highlightedID + cell.onActivate = onSelect.map { select in { select(id) } } // Captions only re-host when the cell's position changes (hover // highlight churn must not rebuild SwiftUI per cell). A // multiplexer caption also follows the tab's title. @@ -124,10 +127,15 @@ final class TabExposeTrayView: UIScrollView { } } headerLabel.text = parts.joined(separator: " · ") + header.isAccessibilityElement = true + header.accessibilityLabel = headerLabel.text let symbol = muxFeed != nil ? "rectangle.split.2x1" : (tabsModel.isProjectGroupingActive ? "folder" : "square.grid.2x2") headerIcon.image = UIImage(systemName: symbol) + } else { + header.isAccessibilityElement = false } applyAppearance(appearance) + accessibilityElements = (scoped ? [header] : []) + cells return entering } diff --git a/rootshell/UI/Tabs/TabExposeView.swift b/rootshell/UI/Tabs/TabExposeView.swift index 6e655e85..66a65e3c 100644 --- a/rootshell/UI/Tabs/TabExposeView.swift +++ b/rootshell/UI/Tabs/TabExposeView.swift @@ -103,6 +103,7 @@ final class TabExposeView: UIView, TabExposeControllerObserver { clipsToBounds = true backdrop.isUserInteractionEnabled = false + backdrop.accessibilityElementsHidden = true backdropMask.fillRule = .evenOdd addSubview(backdrop) addSubview(primary) @@ -113,6 +114,7 @@ final class TabExposeView: UIView, TabExposeControllerObserver { addSubview(pageControl) hero.isUserInteractionEnabled = false + hero.accessibilityElementsHidden = true addSubview(hero) let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) @@ -152,6 +154,7 @@ final class TabExposeView: UIView, TabExposeControllerObserver { func tabExposeDidChangeActivity(_ controller: TabExposeController) { if controller.isActive { isHidden = false + accessibilityViewIsModal = true lastAppliedProgress = -1 resetPage() rebuildPrimary() @@ -167,6 +170,7 @@ final class TabExposeView: UIView, TabExposeControllerObserver { stopDisplayLink() if isFirstResponder { resignFirstResponder() } isHidden = true + accessibilityViewIsModal = false hero.tab = nil syncTerminalConcealment() resetPage() @@ -234,7 +238,8 @@ final class TabExposeView: UIView, TabExposeControllerObserver { muxFeed: controller.showsMultiplexer ? controller.muxFeed : nil, selectedID: controller.currentCellID, highlightedID: controller.highlightedTabID, - appearance: appearance + appearance: appearance, + onSelect: { [weak self] id in self?.controller.select(id) } ) hero.tab = controller.heroTabID.flatMap { tabsModel.tab(withID: $0) } updatePageControl() @@ -254,6 +259,7 @@ final class TabExposeView: UIView, TabExposeControllerObserver { private func makeTray(interactive: Bool) -> TabExposeTrayView { let tray = TabExposeTrayView() tray.isUserInteractionEnabled = interactive + tray.accessibilityElementsHidden = !interactive insertSubview(tray, belowSubview: pageControl) return tray } @@ -634,7 +640,8 @@ extension TabExposeView { muxFeed: neighbor.isMultiplexer ? controller.muxFeed : nil, selectedID: neighbor.currentID, highlightedID: nil, - appearance: appearance + appearance: appearance, + onSelect: { [weak self] id in self?.controller.select(id) } ) companion = tray companionSide = delta @@ -690,7 +697,9 @@ extension TabExposeView { } companion = outgoing outgoing.isUserInteractionEnabled = false + outgoing.accessibilityElementsHidden = true primary.isUserInteractionEnabled = true + primary.accessibilityElementsHidden = false rebuildPrimary() setNeedsLayout() layoutIfNeeded() @@ -763,6 +772,7 @@ extension TabExposeView { scopeCommitDeadline = 0 dropCompanion() primary.isUserInteractionEnabled = true + primary.accessibilityElementsHidden = false } /// Beyond one page the drag stiffens instead of tearing away. @@ -806,7 +816,12 @@ final class TabExposeCellView: UIView { didSet { currentRing.layer.borderColor = currentRingColor.cgColor } } var isCurrent = false { - didSet { updateRings() } + didSet { + updateRings() + accessibilityValue = isCurrent + ? String(localized: "Current tab", comment: "Tab exposé cell: this is the tab that was open before exposé was opened") + : nil + } } var isHighlighted = false { didSet { @@ -825,6 +840,14 @@ final class TabExposeCellView: UIView { didSet { updateRings() } } + var onActivate: (() -> Void)? + + override func accessibilityActivate() -> Bool { + guard let onActivate else { return false } + onActivate() + return true + } + init(tabID: UUID) { self.tabID = tabID super.init(frame: .zero) @@ -847,6 +870,8 @@ final class TabExposeCellView: UIView { currentRing.layer.borderWidth = 1.5 highlightRing.layer.borderWidth = 2.5 updateRings() + + accessibilityTraits = .button } @available(*, unavailable) @@ -857,6 +882,8 @@ final class TabExposeCellView: UIView { muxPreview.isHidden = true mirror.isHidden = false mirror.tab = tab + isAccessibilityElement = true + accessibilityLabel = tab.title } func showMultiplexerTab(_ tab: MuxTab, feed: MultiplexerExposeFeed?) { @@ -865,6 +892,8 @@ final class TabExposeCellView: UIView { muxPreview.isHidden = false muxPreview.feed = feed muxPreview.tab = tab + isAccessibilityElement = true + accessibilityLabel = tab.badge.map { "\(tab.title), \($0)" } ?? tab.title } /// Per display tick: refresh whichever picture is showing. diff --git a/rootshell/UI/Terminal/TerminalView+Session.swift b/rootshell/UI/Terminal/TerminalView+Session.swift index 5e0760bb..e52f414d 100644 --- a/rootshell/UI/Terminal/TerminalView+Session.swift +++ b/rootshell/UI/Terminal/TerminalView+Session.swift @@ -471,9 +471,16 @@ extension Ghostty.TerminalView { /// multiplexer is still running, so the binding must survive those paths. /// (id=agent-attention-raw-mux) func applyConfiguredMultiplexerBinding() { - guard rawMultiplexer == nil else { return } guard let sshConfig = connectionConfig.sshConfigForHistory else { return } + // Keep zmx's transparent identity separate from raw multiplexer + // bindings, since raw bindings also suppress agent attention. + if sshConfig.zmxAutoEnable, let name = sshConfig.zmxSessionNameForConnection { + bindPassthroughMultiplexer(.zmx, sessionName: name, canDetachSwitch: false) + } + + guard rawMultiplexer == nil else { return } + // Auto-connect. Control mode gets its own surface per pane, so only // the plain mode collapses a whole session onto this one. if sshConfig.tmuxAutoEnable, sshConfig.tmuxAutoMode == .regular { @@ -499,12 +506,25 @@ extension Ghostty.TerminalView { /// Sets the binding and re-runs monitor reconcile, so a pane that already /// published an agent card before the multiplexer was known drops it. + /// + /// Binds only multiplexers that own the alternate screen; raw bindings + /// suppress agent attention until ownership is released. func bindRawMultiplexer(_ type: MultiplexerType, sessionName: String?) { + guard type.ownsAlternateScreen else { return } guard rawMultiplexer == nil else { return } rawMultiplexer = .init(type: type, sessionName: sessionName) AgentAttentionCenter.shared.topologyDidChange() } + /// Records a transparent multiplexer identity without affecting agent + /// attention. The session name is required because it cannot be inferred + /// safely from a host with multiple sessions. + func bindPassthroughMultiplexer(_ type: MultiplexerType, sessionName: String, canDetachSwitch: Bool) { + guard !type.ownsAlternateScreen else { return } + guard passthroughMultiplexer == nil else { return } + passthroughMultiplexer = .init(type: type, sessionName: sessionName, canDetachSwitch: canDetachSwitch) + } + /// Maps a configured command to the multiplexer it starts, or nil when it /// starts none. Matches the command word only — an explicit table, never a /// substring search, so an unrelated command mentioning "tmux" is not a @@ -541,6 +561,7 @@ extension Ghostty.TerminalView { let sshConfig = connectionConfig.sshConfigForHistory let multiplexerAutoEnabled = (sshConfig?.tmuxAutoEnable ?? false) || (sshConfig?.herdrAutoEnable ?? false) + || (sshConfig?.zmxAutoEnable ?? false) let launchCommand = sshConfig?.launchCommand // Skip for resumed sessions - the remote shell already has tmux/commands running @@ -611,7 +632,7 @@ extension Ghostty.TerminalView { } } - /// Discovers multiplexer sessions (tmux, zellij, herdr) on the active host or local shell. + /// Discovers multiplexer sessions (tmux, zellij, herdr, zmx) on the active host or local shell. /// Called after the session reports it is ready for input. func discoverSessionsIfConfigured() { // Check which discoveries are enabled (default to true for all) @@ -621,8 +642,10 @@ extension Ghostty.TerminalView { || UserDefaults.standard.bool(forKey: "zellijSessionDiscoveryEnabled") let herdrEnabled = UserDefaults.standard.object(forKey: "herdrSessionDiscoveryEnabled") == nil || UserDefaults.standard.bool(forKey: "herdrSessionDiscoveryEnabled") + let zmxEnabled = UserDefaults.standard.object(forKey: "zmxSessionDiscoveryEnabled") == nil + || UserDefaults.standard.bool(forKey: "zmxSessionDiscoveryEnabled") - guard tmuxEnabled || zellijEnabled || herdrEnabled else { return } + guard tmuxEnabled || zellijEnabled || herdrEnabled || zmxEnabled else { return } #if STANDALONE && targetEnvironment(macCatalyst) if session is CatalystLocalShellSession, case .local = connectionConfig { @@ -634,6 +657,7 @@ extension Ghostty.TerminalView { let skipTmuxSessions = !tmuxEnabled let skipZellijSessions = !zellijEnabled let skipHerdrSessions = !herdrEnabled + let skipZmxSessions = !zmxEnabled let discoverTmuxBindings = tmuxEnabled let discoverZellijBindings = zellijEnabled let workingDirectory = connectionConfig.workingDirectory @@ -644,6 +668,7 @@ extension Ghostty.TerminalView { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings ) @@ -663,16 +688,18 @@ extension Ghostty.TerminalView { // multiplexer types: the connection is already committed to a // multiplexer, so sessions of another type must not pop the picker // over its freshly started UI. - let multiplexerAutoStart = sshConfig.tmuxAutoEnable || sshConfig.herdrAutoEnable + let multiplexerAutoStart = sshConfig.tmuxAutoEnable || sshConfig.herdrAutoEnable || sshConfig.zmxAutoEnable let allowSessionPickerOverlay = !hasLaunchCommand && !hasRemoteCommand && !wasResumed && !multiplexerAutoStart let skipTmuxSessions = !tmuxEnabled || !allowSessionPickerOverlay let skipZellijSessions = !zellijEnabled || !allowSessionPickerOverlay let skipHerdrSessions = !herdrEnabled || !allowSessionPickerOverlay + let skipZmxSessions = !zmxEnabled || !allowSessionPickerOverlay let discoverTmuxBindings = tmuxEnabled let discoverZellijBindings = zellijEnabled - guard discoverTmuxBindings || discoverZellijBindings || !skipTmuxSessions || !skipZellijSessions || !skipHerdrSessions else { + guard discoverTmuxBindings || discoverZellijBindings || !skipTmuxSessions + || !skipZellijSessions || !skipHerdrSessions || !skipZmxSessions else { return } @@ -704,6 +731,7 @@ extension Ghostty.TerminalView { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings ) @@ -716,6 +744,7 @@ extension Ghostty.TerminalView { skipTmuxSessions: skipTmuxSessions, skipZellijSessions: skipZellijSessions, skipHerdrSessions: skipHerdrSessions, + skipZmxSessions: skipZmxSessions, discoverTmuxBindings: discoverTmuxBindings, discoverZellijBindings: discoverZellijBindings, onKeyboardInteractiveChallenge: discoveryKeyboardInteractive @@ -799,7 +828,6 @@ extension Ghostty.TerminalView { let escapedName = session.name.shellEscapedForDoubleQuotes // Wrap in sh -c for portability across all login shells (bash, zsh, fish, csh). // $PATH expands inside sh, not the outer shell. - let pathPrefix = "PATH=\"$PATH:/usr/local/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin:/snap/bin\"" let attachCommand: String switch session.type { case .tmux: @@ -819,14 +847,45 @@ extension Ghostty.TerminalView { // server. "default" is the literal name of the default session. attachCommand = "herdr session attach \"\(escapedName)\"" bindRawMultiplexer(.herdr, sessionName: session.name) + case .zmx: + // zmx is transparent, so preserve its identity without suppressing + // the inner program's agent state. Clear the prefix because the + // discovered name is already the fully resolved socket name. + attachCommand = "ZMX_SESSION_PREFIX= zmx attach \"\(escapedName)\"" + bindPassthroughMultiplexer(.zmx, sessionName: session.name, canDetachSwitch: true) + + // Use the reported directory until OSC 7 provides a fresher value. + if let cwd = session.workingDirectory, cwd.hasPrefix("/") { + handlePwdChange(cwd) + } + } + let command = multiplexerAttachInputLine(attachCommand) + if session.type == .zmx, userOverrideTitle == nil { + // Preserve the session name until a real OSC 2 title arrives; + // suppress the shell's one-shot command echo. + title = session.name + pendingCommandEcho = ( + command: command.trimmingCharacters(in: .whitespacesAndNewlines), + until: Date().addingTimeInterval(Self.commandEchoTitleWindow) + ) } - let script = "\(pathPrefix) \(attachCommand)" - let command = "sh -c '\(shellEscapeForSingleQuotes(script))'\n" if let data = command.data(using: .utf8) { sendUserInput(data) } dismissSessionDiscovery() } + + /// Builds the zmx attach line used when focus reattaches through the shell. + func zmxAttachInputLine(sessionName: String) -> String { + let escapedName = sessionName.shellEscapedForDoubleQuotes + return multiplexerAttachInputLine("ZMX_SESSION_PREFIX= zmx attach \"\(escapedName)\"") + } + + private func multiplexerAttachInputLine(_ attachCommand: String) -> String { + let pathPrefix = "PATH=\"$PATH:/usr/local/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin:/snap/bin\"" + let script = "\(pathPrefix) \(attachCommand)" + return "sh -c '\(shellEscapeForSingleQuotes(script))'\n" + } } // MARK: - Session Monitoring diff --git a/rootshell/UI/Terminal/TerminalView+SessionHost.swift b/rootshell/UI/Terminal/TerminalView+SessionHost.swift index 3a88cb4c..d80f924d 100644 --- a/rootshell/UI/Terminal/TerminalView+SessionHost.swift +++ b/rootshell/UI/Terminal/TerminalView+SessionHost.swift @@ -68,6 +68,10 @@ extension Ghostty.TerminalView: TerminalSessionControllerHost { // Cancel connection success timer if session ends prematurely. self.sessionController.cancelConnectionSuccessTimer() + // Reconnection starts outside any picker-attached passthrough session; + // configured bindings are restored when the new session becomes ready. + self.passthroughMultiplexer = nil + // Check if reconnection manager is in a state that should keep tab open if let state = self.sessionController.reconnectionState { switch state { diff --git a/rootshell/UI/Terminal/TerminalView.swift b/rootshell/UI/Terminal/TerminalView.swift index 24697970..f5f335e2 100644 --- a/rootshell/UI/Terminal/TerminalView.swift +++ b/rootshell/UI/Terminal/TerminalView.swift @@ -233,6 +233,45 @@ extension Ghostty { /// Title provided by the terminal session (from escape sequences) var sessionProvidedTitle: String? + /// A shell title emitted as an echo of an attach command typed by the app. + var pendingCommandEcho: (command: String, until: Date)? + + static let commandEchoTitleWindow: TimeInterval = 2 + static let commandEchoMatchPrefix = 16 + + /// Consumes a matching, one-shot command echo. + func consumesCommandEcho(_ title: String) -> Bool { + guard let echo = pendingCommandEcho else { return false } + guard Date() < echo.until else { + pendingCommandEcho = nil + return false + } + let reported = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !reported.isEmpty else { return false } + // Shell integrations may truncate or decorate command titles. + let needle = String(echo.command.prefix(Self.commandEchoMatchPrefix)) + guard needle.count >= Self.commandEchoMatchPrefix else { + guard reported == echo.command else { return false } + pendingCommandEcho = nil + return true + } + guard reported.contains(needle) else { return false } + pendingCommandEcho = nil + return true + } + + /// Deadline for suppressing titles from a briefly exposed fallback shell. + var titleSuppressedUntil: Date? + + func suppressesTitleUpdate() -> Bool { + guard let until = titleSuppressedUntil else { return false } + guard Date() < until else { + titleSuppressedUntil = nil + return false + } + return true + } + /// Most recent working directory the terminal has reported. Cached in a /// non-observed property so we can keep it up to date while the resume /// gate is up without triggering SwiftUI scene updates (see @@ -389,8 +428,16 @@ extension Ghostty { /// binding instantly. Only a primary frame AFTER ownership means /// the multiplexer detached or exited. var hasOwnedAltScreen: Bool = false + /// Whether focus may detach this zmx client and reattach through + /// the shell beneath it. Auto-started clients have no shell to + /// fall back to, so they must use leader-addressed IPC instead. + var canDetachSwitch: Bool = false } var rawMultiplexer: RawMultiplexerBinding? + + /// A transparent multiplexer identity that does not suppress agent + /// attention or depend on alternate-screen ownership. + var passthroughMultiplexer: RawMultiplexerBinding? nonisolated(unsafe) var tmuxDetachInProgressAtomic: Bool = false var isTmuxDetachInProgress: Bool { @@ -4463,6 +4510,14 @@ extension Ghostty.TerminalView: GhosttyActionDelegate { // Timer fires on the run loop that scheduled it (main). MainActor.assumeIsolated { guard let self = self else { return } + if self.suppressesTitleUpdate() { + Ghostty.logger.debug("Swallowed transient mid-switch title: \(title)") + return + } + if self.consumesCommandEcho(title) { + Ghostty.logger.debug("Swallowed command-echo title: \(title)") + return + } // Always cache the session-provided title so foreground replay has it. self.sessionProvidedTitle = title // Agent identity/state from title rules (cheap, no screen @@ -4490,7 +4545,9 @@ extension Ghostty.TerminalView: GhosttyActionDelegate { paneUUID: uuid, exitCode: exitCode, duration: duration) } - func handlePwdChange(_ pwd: String) { + func handlePwdChange(_ reported: String) { + // Decode OSC 7/file URLs at the point where all pwd sources converge. + let pwd = WorkingDirectoryURI.path(reported) // Always cache so we don't lose the latest value while backgrounded. self.sessionProvidedPwd = pwd guard !Ghostty.isAppBackgroundedAtomic else { return }