From 96c0da257c7cb49133830aa08e2f3006ae6cd17c Mon Sep 17 00:00:00 2001 From: Jason Gauci Date: Sun, 30 Aug 2026 17:25:24 -0500 Subject: [PATCH 1/9] Add HTM multiplexer takeover behind Feature_HtmIntegration. Windows Terminal has no Hyper-style plugin API, so JSON fragments cannot intercept splits, new tabs, or pane close. Wrap every ConPTY in HtmLeaderConnection (the DebugTapConnection pattern) so typing `htm` in an existing profile can steal that PTY, apply INIT_STATE as follower panes with no local process, and map WT split/new-tab/close onto htmd. The wire protocol is unchanged from EternalTerminal and hyper-htm (ESC[###q / ESC[$$$q, 1-byte header + 8-char b64 length, SESSION_END is one byte). Feature_HtmIntegration is AlwaysEnabled in Dev and disabled for Release and WindowsInbox so the diff stays reviewable upstream. Optional profile env HTM_BIN_DIR is prepended to PATH so a local htm.exe/htmd.exe build is found without changing the default commandline. Co-authored-by: Cursor --- doc/specs/htm-integration.md | 139 ++++++ .../TerminalApp/AppActionHandlers.cpp | 34 ++ src/cascadia/TerminalApp/HtmConnections.cpp | 231 ++++++++++ src/cascadia/TerminalApp/HtmConnections.h | 85 ++++ src/cascadia/TerminalApp/HtmProtocol.h | 246 +++++++++++ src/cascadia/TerminalApp/HtmSession.cpp | 394 ++++++++++++++++++ src/cascadia/TerminalApp/HtmSession.h | 58 +++ src/cascadia/TerminalApp/TabManagement.cpp | 12 + .../TerminalApp/TerminalAppLib.vcxproj | 5 + .../TerminalAppLib.vcxproj.filters | 5 + src/cascadia/TerminalApp/TerminalPage.cpp | 150 ++++++- src/cascadia/TerminalApp/TerminalPage.h | 11 + src/cascadia/ut_app/HtmProtocolTests.cpp | 80 ++++ .../ut_app/TerminalApp.UnitTests.vcxproj | 1 + src/features.xml | 10 + 15 files changed, 1459 insertions(+), 2 deletions(-) create mode 100644 doc/specs/htm-integration.md create mode 100644 src/cascadia/TerminalApp/HtmConnections.cpp create mode 100644 src/cascadia/TerminalApp/HtmConnections.h create mode 100644 src/cascadia/TerminalApp/HtmProtocol.h create mode 100644 src/cascadia/TerminalApp/HtmSession.cpp create mode 100644 src/cascadia/TerminalApp/HtmSession.h create mode 100644 src/cascadia/ut_app/HtmProtocolTests.cpp diff --git a/doc/specs/htm-integration.md b/doc/specs/htm-integration.md new file mode 100644 index 00000000000..47b004c49fb --- /dev/null +++ b/doc/specs/htm-integration.md @@ -0,0 +1,139 @@ +--- +author: MisterTea +created on: 2026-08-30 +last updated: 2026-08-30 +issue id: n/a +--- + +# HTM (headless terminal multiplexer) integration + +## Abstract + +This spec describes how Windows Terminal detects an EternalTerminal `htm` session on an existing ConPTY connection, takes over tabs/panes so they map onto `htmd`, and tears the session down without a new `connectionType`. The work is gated by `Feature_HtmIntegration` (enabled in Dev, disabled in Release and WindowsInbox). + +## Inspiration + +[hyper-htm](https://github.com/MisterTea/hyper-htm) wraps Hyper so that running `htm` in a tab steals that PTY, creates follower panes with no local PTY, and maps split/new-tab/close onto the `htmd` daemon. Windows Terminal has no Hyper-style plugin API: JSON fragments can inject profiles and color schemes only. Shell integration (OSC 133) and `ShellExtension` do not intercept splits. Matching that UX requires TerminalApp to wrap ConPTY, the same pattern as `DebugTapConnection`. + +EternalTerminal `htm`/`htmd` on Windows uses ConPTY for pane shells and AF_UNIX IPC at `%TEMP%\htm..ipc`. The wire protocol is unchanged so hyper-htm and Windows Terminal stay compatible. + +## Solution Design + +``` + Windows Terminal EternalTerminal + ┌─────────────────────────────┐ ┌──────────────────────┐ + │ Leader pane │ PTY │ htm.exe byte bridge │ + │ ConPTY + HtmLeaderConnection ────────►│ │ │ + │ Follower panes │ framed │ ▼ AF_UNIX │ + │ HtmFollowerConnection │ packets │ htmd.exe mux daemon │ + │ HtmSession (per window) │ │ ConPTY per pane │ + └─────────────────────────────┘ └──────────────────────┘ +``` + +### Why wrap ConPTY instead of a new `connectionType` + +Users type `htm` in an existing profile (PowerShell, cmd, WSL). A dedicated `connectionType` would require a separate profile and would not take over a tab that is already running. Wrapping every ConPTY in `HtmLeaderConnection` (behind the feature flag) matches Hyper: pass-through until `ESC[###q`, then consume framed packets. + +### Wire protocol + +Compatible with EternalTerminal `HtmHeaderCodes.hpp` and `hyper-htm/htm-core.js`. + +- Init: `ESC[###q` (pass through bytes before this; hold a partial match across chunks) +- Exit: `ESC[$$$q` (leave HTM mode; leader shows a normal shell again) +- Frame: `[1-byte header][8-char base64 of little-endian int32 length][payload]` +- `SESSION_END` (`D`) is a single byte with no length field + +| Header | Payload | Direction | +|--------|---------|-----------| +| `1` INSERT_KEYS | 36-char pane UUID + base64 UTF-8 keys | client → server | +| `2` INIT_STATE | JSON multiplexer state | server → client | +| `3` CLIENT_CLOSE_PANE | pane UUID | client → server | +| `4` APPEND_TO_PANE | pane UUID + base64 output | server → client | +| `5` NEW_TAB | tab UUID + pane UUID | client → server | +| `8` SERVER_CLOSE_PANE | pane UUID | server → client | +| `9` NEW_SPLIT | source UUID + pane UUID + `'1'` vertical / `'0'` horizontal | client → server | +| `A` RESIZE_PANE | base64 int32 cols + base64 int32 rows + pane UUID | client → server | +| `B` DEBUG_LOG | base64 text | server → client | +| `C` INSERT_DEBUG_KEYS | raw keys (leader keystrokes, Escape disconnect) | client → server | +| `D` SESSION_END | none | either | + +UUIDs are 36-character `GuidToPlainString` values (no braces). HTM `'1'` is a vertical divider (Windows Terminal left/right); `'0'` is horizontal (up/down). + +### Types (`src/cascadia/TerminalApp/`) + +| Type | Role | +|------|------| +| `HtmProtocol` | Framing, CSI consume, packet parse | +| `HtmLeaderConnection` | Wraps ConPTY; pass-through until init CSI; then consume packets and route leader keys as `INSERT_DEBUG_KEYS` | +| `HtmFollowerConnection` | No process. `WriteInput` → `INSERT_KEYS`; `Resize` → `RESIZE_PANE`; `Close` → `CLIENT_CLOSE_PANE` | +| `HtmSession` | Per-window on `TerminalPage`: UUID map, INIT_STATE layout, user split/tab intercept | + +On `INIT_STATE`, the first pane of the first tab maps onto the existing leader (no second ConPTY). Remaining panes are created with `HtmFollowerConnection` via sequential binary splits (HTM n-way splits). `APPEND_TO_PANE` / `DEBUG_LOG` are injected into the mapped `TermControl`s. + +While a session is active, split and new-tab on an HTM pane create a follower and send `NEW_SPLIT` / `NEW_TAB` instead of spawning ConPTY. Closing a follower sends `CLIENT_CLOSE_PANE`. Closing the leader or seeing `SESSION_END` / `ESC[$$$q` tears down followers and returns the leader to a normal shell; the Windows Terminal window stays open (Hyper closes the window; Windows Terminal only ends the HTM session). + +### Settings + +Put `htm.exe` / `htmd.exe` on `PATH`, or set a profile environment variable so a local EternalTerminal build is found: + +```json +{ + "profiles": { + "defaults": { + "environment": { + "HTM_BIN_DIR": "C:\\path\\to\\et\\build" + } + } + } +} +``` + +When `HTM_BIN_DIR` is set, Terminal prepends it to `PATH` for that ConPTY. An optional profile `"commandline": "htm.exe"` is not required for takeover. + +## UI/UX Design + +1. Open Windows Terminal, run `htm` in any ConPTY profile. +2. Extra panes/tabs appear matching the multiplexer state (including restored scrollback). +3. Typing in a follower is injected into `htmd`; output streams back as `APPEND_TO_PANE`. +4. Split / new tab from an HTM pane create remote panes, not local shells. +5. Escape (via `htm` debug keys) or `ESC[$$$q` leaves HTM mode; `htm` again restores the session. + +## Capabilities + +### Accessibility + +Follower panes are normal `TermControl` instances. Screen readers see the same text buffer as any other pane. No new UI chrome. + +### Security + +`htm`/`htmd` already run as the current user. AF_UNIX IPC is per-user under `%TEMP%`. This feature only interprets bytes already produced by a user-started process. `HTM_BIN_DIR` is an explicit profile setting. + +### Reliability + +Malformed frames drop HTM mode instead of wedging the connection. Leader close disconnects the session without closing the whole window. Unknown HTM headers cause `htmd` to disconnect that client. + +### Compatibility + +Disabled in Release and WindowsInbox via `Feature_HtmIntegration`. Dev builds wrap ConPTY; until `htm` prints `ESC[###q`, behavior is unchanged. The wire protocol is not versioned independently of EternalTerminal / hyper-htm. + +### Performance, Power, and Efficiency + +Pass-through copies ConPTY output until init. After takeover, framed packets replace raw PTY traffic for followers (no extra processes). Overhead is comparable to `DebugTapConnection`. + +## Potential Issues + +- This environment cannot compile Windows Terminal; first verification needs Windows 10 2004+ and Visual Studio. +- JSON fragment extensions cannot provide this behavior; an upstream plugin API (GH#4000) would be a larger design. +- Undo-close of an HTM follower may recreate a local ConPTY instead of a follower. +- n-way HTM splits are approximated with sequential 50/50 binary splits. + +## Future considerations + +A first-class `connectionType` or connection-wrapper extension point would let this live out-of-tree. Until then, a feature-flagged branch is the reviewable shape for an upstream PR. + +## Resources + +- EternalTerminal `src/htm/` (`HtmHeaderCodes.hpp`, `HtmClient`, `HtmServer`, `TerminalHandler`) +- [hyper-htm](https://github.com/MisterTea/hyper-htm) `htm-core.js`, `index.js` +- `DebugTapConnection` in TerminalApp +- Windows Terminal GH#4000 (extensibility) diff --git a/src/cascadia/TerminalApp/AppActionHandlers.cpp b/src/cascadia/TerminalApp/AppActionHandlers.cpp index c543ef32198..2f21f61d8ac 100644 --- a/src/cascadia/TerminalApp/AppActionHandlers.cpp +++ b/src/cascadia/TerminalApp/AppActionHandlers.cpp @@ -284,6 +284,26 @@ namespace winrt::TerminalApp::implementation const auto& activeTab{ _senderOrFocusedTab(sender) }; + if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) + { + const auto focusedConn = _HtmFocusedConnection(); + const auto sourceId = _HtmPaneIdFromConnection(focusedConn); + if (!sourceId.empty()) + { + const auto direction = realArgs.SplitDirection(); + const bool vertical = direction != SplitDirection::Up && direction != SplitDirection::Down; + if (const auto follower{ _htmSession->CreateFollowerForUserSplit(sourceId, vertical) }) + { + _SplitPane(activeTab, + direction, + realArgs.SplitSize(), + _MakePane(realArgs.ContentArgs(), duplicateFromTab, follower)); + args.Handled(true); + return; + } + } + } + _SplitPane(activeTab, realArgs.SplitDirection(), // This is safe, we're already filtering so the value is (0, 1) @@ -472,6 +492,20 @@ namespace winrt::TerminalApp::implementation return; } + if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) + { + const auto focusedConn = _HtmFocusedConnection(); + if (!_HtmPaneIdFromConnection(focusedConn).empty()) + { + if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) + { + _CreateNewTabFromPane(_MakePane(realArgs.ContentArgs(), nullptr, follower)); + args.Handled(true); + return; + } + } + } + LOG_IF_FAILED(_OpenNewTab(realArgs.ContentArgs())); args.Handled(true); } diff --git a/src/cascadia/TerminalApp/HtmConnections.cpp b/src/cascadia/TerminalApp/HtmConnections.cpp new file mode 100644 index 00000000000..ae3aef262a2 --- /dev/null +++ b/src/cascadia/TerminalApp/HtmConnections.cpp @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#include "pch.h" +#include "HtmConnections.h" +#include "HtmSession.h" + +using namespace winrt::Microsoft::Terminal::TerminalConnection; +using namespace ::Microsoft::Terminal::Htm; + +namespace winrt::TerminalApp::implementation +{ + HtmLeaderConnection::HtmLeaderConnection(ITerminalConnection wrapped, HtmSession* session) : + _wrapped{ std::move(wrapped) }, + _session{ session } + { + _outputRevoker = _wrapped.TerminalOutput(winrt::auto_revoke, { get_weak(), &HtmLeaderConnection::_OutputHandler }); + _stateChangedRevoker = _wrapped.StateChanged(winrt::auto_revoke, [weak = get_weak()](auto&&, auto&&) { + if (const auto self = weak.get()) + { + self->StateChanged.raise(*self, nullptr); + } + }); + } + + void HtmLeaderConnection::Initialize(const Windows::Foundation::Collections::ValueSet& settings) + { + _wrapped.Initialize(settings); + } + + void HtmLeaderConnection::Start() + { + _wrapped.Start(); + } + + void HtmLeaderConnection::WriteInput(const winrt::array_view data) + { + if (_htmMode) + { + const auto utf8 = til::u16u8(winrt_array_to_wstring_view(data)); + WriteRaw(FrameInsertDebugKeys(utf8)); + return; + } + _wrapped.WriteInput(data); + } + + void HtmLeaderConnection::Resize(uint32_t rows, uint32_t columns) + { + _wrapped.Resize(rows, columns); + if (_htmMode && !_paneId.empty()) + { + WriteRaw(FrameResizePane(_paneId, static_cast(columns), static_cast(rows))); + } + } + + void HtmLeaderConnection::Close() + { + if (_session && _htmMode) + { + _session->DetachLeader(this); + } + _outputRevoker.revoke(); + _stateChangedRevoker.revoke(); + if (_wrapped) + { + _wrapped.Close(); + } + _wrapped = nullptr; + } + + winrt::guid HtmLeaderConnection::SessionId() const noexcept + { + return _wrapped ? _wrapped.SessionId() : winrt::guid{}; + } + + ConnectionState HtmLeaderConnection::State() const noexcept + { + return _wrapped ? _wrapped.State() : ConnectionState::Closed; + } + + void HtmLeaderConnection::WriteRaw(std::string_view bytes) + { + if (!_wrapped || bytes.empty()) + { + return; + } + const auto wide = til::u8u16(bytes); + _wrapped.WriteInput(winrt_wstring_to_array_view(wide)); + } + + void HtmLeaderConnection::InjectOutput(std::string_view utf8) + { + if (utf8.empty()) + { + return; + } + const auto wide = til::u8u16(utf8); + TerminalOutput.raise(winrt_wstring_to_array_view(wide)); + } + + void HtmLeaderConnection::_OutputHandler(const winrt::array_view str) + { + const auto utf8 = til::u16u8(winrt_array_to_wstring_view(str)); + if (_htmMode) + { + if (utf8.find(ExitSequence) != std::string::npos) + { + _htmMode = false; + if (_session) + { + _session->HandleExitSequence(); + } + return; + } + _ProcessHtmBytes(utf8); + return; + } + + const auto result = ConsumeInitPayload(_pendingInit, utf8); + _pendingInit = result.pending; + if (!result.prefix.empty()) + { + const auto wide = til::u8u16(result.prefix); + TerminalOutput.raise(winrt_wstring_to_array_view(wide)); + } + if (result.matched) + { + _htmMode = true; + if (_session) + { + _session->AttachLeader(this); + } + if (!result.remainder.empty()) + { + _ProcessHtmBytes(result.remainder); + } + } + } + + void HtmLeaderConnection::_ProcessHtmBytes(std::string_view utf8) + { + _htmBuffer.append(utf8); + auto [packets, rest] = ParsePackets(_htmBuffer); + _htmBuffer = std::move(rest); + for (const auto& packet : packets) + { + if (packet.invalidLength) + { + _htmMode = false; + if (_session) + { + _session->HandleExitSequence(); + } + return; + } + if (packet.header == SessionEnd) + { + _htmMode = false; + if (_session) + { + _session->HandlePacket(packet.header, packet.payload); + } + return; + } + if (_session) + { + _session->HandlePacket(packet.header, packet.payload); + } + } + } + + HtmFollowerConnection::HtmFollowerConnection(HtmSession* session, std::string paneId) : + _session{ session }, + _paneId{ std::move(paneId) } + { + } + + void HtmFollowerConnection::Start() + { + _started = true; + StateChanged.raise(*this, nullptr); + if (_session) + { + _session->RegisterFollower(this); + } + } + + void HtmFollowerConnection::WriteInput(const winrt::array_view data) + { + if (!_session) + { + return; + } + const auto utf8 = til::u16u8(winrt_array_to_wstring_view(data)); + _session->WriteToLeader(FrameInsertKeys(_paneId, utf8)); + } + + void HtmFollowerConnection::Resize(uint32_t rows, uint32_t columns) + { + _rows = rows; + _cols = columns; + if (_session) + { + _session->WriteToLeader(FrameResizePane(_paneId, static_cast(columns), static_cast(rows))); + } + } + + void HtmFollowerConnection::Close() + { + if (_session) + { + if (!_suppressClosePacket) + { + _session->WriteToLeader(FrameClientClosePane(_paneId)); + } + _session->UnregisterFollower(this); + } + _session = nullptr; + StateChanged.raise(*this, nullptr); + } + + void HtmFollowerConnection::InjectOutput(std::string_view utf8) + { + if (utf8.empty()) + { + return; + } + const auto wide = til::u8u16(utf8); + TerminalOutput.raise(winrt_wstring_to_array_view(wide)); + } +} diff --git a/src/cascadia/TerminalApp/HtmConnections.h b/src/cascadia/TerminalApp/HtmConnections.h new file mode 100644 index 00000000000..c76bdf25d1d --- /dev/null +++ b/src/cascadia/TerminalApp/HtmConnections.h @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#pragma once + +#include "HtmProtocol.h" + +#include +#include + +namespace winrt::TerminalApp::implementation +{ + class HtmSession; + + class HtmLeaderConnection : public winrt::implements + { + public: + HtmLeaderConnection(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection wrapped, + HtmSession* session); + + void Initialize(const Windows::Foundation::Collections::ValueSet& settings); + void Start(); + void WriteInput(const winrt::array_view data); + void Resize(uint32_t rows, uint32_t columns); + void Close(); + + winrt::guid SessionId() const noexcept; + winrt::Microsoft::Terminal::TerminalConnection::ConnectionState State() const noexcept; + + void WriteRaw(std::string_view bytes); + void InjectOutput(std::string_view utf8); + bool InHtmMode() const noexcept { return _htmMode; } + void SetPaneId(std::string paneId) { _paneId = std::move(paneId); } + const std::string& PaneId() const noexcept { return _paneId; } + + til::event TerminalOutput; + til::typed_event StateChanged; + + private: + void _OutputHandler(const winrt::array_view str); + void _ProcessHtmBytes(std::string_view utf8); + + winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection _wrapped{ nullptr }; + winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection::TerminalOutput_revoker _outputRevoker; + winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection::StateChanged_revoker _stateChangedRevoker; + HtmSession* _session{ nullptr }; + bool _htmMode{ false }; + std::string _pendingInit; + std::string _htmBuffer; + std::string _paneId; + }; + + class HtmFollowerConnection : public winrt::implements + { + public: + HtmFollowerConnection(HtmSession* session, std::string paneId); + + void Initialize(const Windows::Foundation::Collections::ValueSet& /*settings*/){}; + void Start(); + void WriteInput(const winrt::array_view data); + void Resize(uint32_t rows, uint32_t columns); + void Close(); + + winrt::guid SessionId() const noexcept { return {}; } + winrt::Microsoft::Terminal::TerminalConnection::ConnectionState State() const noexcept + { + return winrt::Microsoft::Terminal::TerminalConnection::ConnectionState::Connected; + } + + const std::string& PaneId() const noexcept { return _paneId; } + void InjectOutput(std::string_view utf8); + void SetSuppressClosePacket(bool value) noexcept { _suppressClosePacket = value; } + + til::event TerminalOutput; + til::typed_event StateChanged; + + private: + HtmSession* _session{ nullptr }; + std::string _paneId; + bool _started{ false }; + bool _suppressClosePacket{ false }; + uint32_t _rows{ 24 }; + uint32_t _cols{ 80 }; + }; +} diff --git a/src/cascadia/TerminalApp/HtmProtocol.h b/src/cascadia/TerminalApp/HtmProtocol.h new file mode 100644 index 00000000000..bc5cf9f2849 --- /dev/null +++ b/src/cascadia/TerminalApp/HtmProtocol.h @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +// HTM (headless terminal multiplexer) wire protocol, matching +// EternalTerminal HtmHeaderCodes and hyper-htm/htm-core.js. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Microsoft::Terminal::Htm +{ + inline constexpr char InsertKeys = '1'; + inline constexpr char InitState = '2'; + inline constexpr char ClientClosePane = '3'; + inline constexpr char AppendToPane = '4'; + inline constexpr char NewTab = '5'; + inline constexpr char ServerClosePane = '8'; + inline constexpr char NewSplit = '9'; + inline constexpr char ResizePane = 'A'; + inline constexpr char DebugLog = 'B'; + inline constexpr char InsertDebugKeys = 'C'; + inline constexpr char SessionEnd = 'D'; + + inline constexpr size_t UuidLength = 36; + inline constexpr std::string_view InitSequence{ "\x1b[###q" }; + inline constexpr std::string_view ExitSequence{ "\x1b[$$$q" }; + + inline constexpr char VerticalSplit = '1'; + inline constexpr char HorizontalSplit = '0'; + + struct Packet + { + char header{}; + std::string payload; + bool invalidLength{ false }; + }; + + inline std::string Base64Encode(const void* data, size_t size) + { + static constexpr char kTable[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const auto* bytes = static_cast(data); + std::string out; + out.reserve(((size + 2) / 3) * 4); + for (size_t i = 0; i < size; i += 3) + { + const unsigned int b0 = bytes[i]; + const unsigned int b1 = i + 1 < size ? bytes[i + 1] : 0; + const unsigned int b2 = i + 2 < size ? bytes[i + 2] : 0; + const unsigned int triple = (b0 << 16) | (b1 << 8) | b2; + out.push_back(kTable[(triple >> 18) & 0x3F]); + out.push_back(kTable[(triple >> 12) & 0x3F]); + out.push_back(i + 1 < size ? kTable[(triple >> 6) & 0x3F] : '='); + out.push_back(i + 2 < size ? kTable[triple & 0x3F] : '='); + } + return out; + } + + inline std::string Base64Decode(std::string_view encoded) + { + static constexpr signed char kDecode[256] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, + -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 + }; + std::string out; + int val = 0; + int valb = -8; + for (unsigned char c : encoded) + { + if (c == '=') + { + break; + } + const signed char d = kDecode[c]; + if (d < 0) + { + continue; + } + val = (val << 6) + d; + valb += 6; + if (valb >= 0) + { + out.push_back(char((val >> valb) & 0xFF)); + valb -= 8; + } + } + return out; + } + + inline std::string EncodeLength(int32_t length) + { + return Base64Encode(&length, sizeof(length)); + } + + inline int32_t DecodeLength(std::string_view b64) + { + const auto bytes = Base64Decode(b64.substr(0, 8)); + if (bytes.size() < 4) + { + return -1; + } + int32_t value = 0; + memcpy(&value, bytes.data(), 4); + return value; + } + + inline std::string FramePacket(char header, std::string_view payload) + { + std::string out; + out.reserve(9 + payload.size()); + out.push_back(header); + out += EncodeLength(static_cast(payload.size())); + out.append(payload.data(), payload.size()); + return out; + } + + inline std::string FrameInsertKeys(std::string_view paneId, std::string_view utf8Keys) + { + const auto encoded = Base64Encode(utf8Keys.data(), utf8Keys.size()); + std::string payload; + payload.reserve(paneId.size() + encoded.size()); + payload.append(paneId); + payload.append(encoded); + return FramePacket(InsertKeys, payload); + } + + inline std::string FrameInsertDebugKeys(std::string_view keys) + { + return FramePacket(InsertDebugKeys, keys); + } + + inline std::string FrameNewTab(std::string_view tabId, std::string_view paneId) + { + std::string payload; + payload.append(tabId); + payload.append(paneId); + return FramePacket(NewTab, payload); + } + + inline std::string FrameNewSplit(std::string_view sourceId, std::string_view paneId, bool vertical) + { + std::string payload; + payload.append(sourceId); + payload.append(paneId); + payload.push_back(vertical ? VerticalSplit : HorizontalSplit); + return FramePacket(NewSplit, payload); + } + + inline std::string FrameResizePane(std::string_view paneId, int32_t cols, int32_t rows) + { + std::string payload = Base64Encode(&cols, 4) + Base64Encode(&rows, 4); + payload.append(paneId); + return FramePacket(ResizePane, payload); + } + + inline std::string FrameClientClosePane(std::string_view paneId) + { + return FramePacket(ClientClosePane, paneId); + } + + inline size_t LongestInitPrefix(std::string_view data, std::string_view needle) + { + const auto max = std::min(data.size(), needle.size() - 1); + for (size_t n = max; n > 0; --n) + { + if (needle.substr(0, n) == data.substr(data.size() - n)) + { + return n; + } + } + return 0; + } + + struct ConsumeInitResult + { + bool matched{ false }; + std::string prefix; + std::string remainder; + std::string pending; + }; + + inline ConsumeInitResult ConsumeInitPayload(std::string_view pending, std::string_view payload, std::string_view needle = InitSequence) + { + std::string data; + data.reserve(pending.size() + payload.size()); + data.append(pending); + data.append(payload); + const auto initAt = data.find(needle); + if (initAt != std::string::npos) + { + return { true, data.substr(0, initAt), data.substr(initAt + needle.size()), {} }; + } + const auto hold = LongestInitPrefix(data, needle); + if (hold > 0) + { + return { false, data.substr(0, data.size() - hold), {}, data.substr(data.size() - hold) }; + } + return { false, data, {}, {} }; + } + + inline std::pair, std::string> ParsePackets(std::string_view buffer) + { + std::vector packets; + size_t offset = 0; + while (offset < buffer.size()) + { + const char header = buffer[offset]; + if (header == SessionEnd) + { + packets.push_back({ header, {}, false }); + offset += 1; + break; + } + if (buffer.size() - offset < 9) + { + break; + } + const auto length = DecodeLength(buffer.substr(offset + 1, 8)); + if (length < 0) + { + packets.push_back({ header, {}, true }); + break; + } + if (buffer.size() - offset - 9 < static_cast(length)) + { + break; + } + packets.push_back({ header, std::string(buffer.substr(offset + 9, static_cast(length))), false }); + offset += 9 + static_cast(length); + } + return { std::move(packets), std::string(buffer.substr(offset)) }; + } +} diff --git a/src/cascadia/TerminalApp/HtmSession.cpp b/src/cascadia/TerminalApp/HtmSession.cpp new file mode 100644 index 00000000000..959cc988b5c --- /dev/null +++ b/src/cascadia/TerminalApp/HtmSession.cpp @@ -0,0 +1,394 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#include "pch.h" +#include "HtmSession.h" +#include "HtmConnections.h" +#include "TerminalPage.h" + +#include "../../types/inc/utils.hpp" + +#include +#include + +using namespace winrt::Microsoft::Terminal::TerminalConnection; +using namespace winrt::Windows::Data::Json; +using namespace ::Microsoft::Terminal::Htm; +using namespace ::Microsoft::Console; + +namespace winrt::TerminalApp::implementation +{ + HtmSession::HtmSession(TerminalPage* page) : + _page{ page } + { + } + + void HtmSession::AttachLeader(HtmLeaderConnection* leader) + { + std::lock_guard lock{ _mutex }; + _leader = leader; + } + + void HtmSession::DetachLeader(HtmLeaderConnection* leader) + { + bool wasLeader = false; + { + std::lock_guard lock{ _mutex }; + if (_leader == leader) + { + _leader = nullptr; + wasLeader = true; + } + } + if (wasLeader) + { + _exitHtmMode(true); + } + } + + bool HtmSession::IsActive() const noexcept + { + return _leader != nullptr; + } + + bool HtmSession::IsHtmConnection(const ITerminalConnection& connection) const + { + if (!connection) + { + return false; + } + if (const auto leader{ connection.try_as() }) + { + return leader.get() == _leader; + } + if (const auto follower{ connection.try_as() }) + { + return _followers.contains(follower->PaneId()); + } + return false; + } + + void HtmSession::RegisterFollower(HtmFollowerConnection* follower) + { + if (!follower) + { + return; + } + std::lock_guard lock{ _mutex }; + _followers[follower->PaneId()] = follower; + _initializedPanes.insert(follower->PaneId()); + } + + void HtmSession::UnregisterFollower(HtmFollowerConnection* follower) + { + if (!follower) + { + return; + } + std::lock_guard lock{ _mutex }; + _followers.erase(follower->PaneId()); + } + + void HtmSession::WriteToLeader(std::string_view packet) + { + if (_leader) + { + _leader->WriteRaw(packet); + } + } + + std::string HtmSession::GenerateUuid() + { + return til::u16u8(Utils::GuidToPlainString(Utils::CreateGuid())); + } + + void HtmSession::HandleExitSequence() + { + _exitHtmMode(true); + } + + void HtmSession::HandlePacket(char header, std::string_view payload) + { + switch (header) + { + case InitState: + _applyInitState(std::string{ payload }); + break; + case AppendToPane: + { + if (payload.size() < UuidLength) + { + break; + } + const auto paneId = std::string{ payload.substr(0, UuidLength) }; + const auto decoded = Base64Decode(payload.substr(UuidLength)); + _appendToPane(paneId, decoded); + break; + } + case DebugLog: + { + const auto decoded = Base64Decode(payload); + if (_leader) + { + _leader->InjectOutput(decoded); + } + break; + } + case ServerClosePane: + { + if (payload.size() >= UuidLength) + { + _closePaneFromServer(std::string{ payload.substr(0, UuidLength) }); + } + break; + } + case SessionEnd: + _exitHtmMode(true); + break; + default: + break; + } + } + + ITerminalConnection HtmSession::CreateFollowerForUserSplit(const std::string& sourcePaneId, bool vertical) + { + if (!_leader || sourcePaneId.empty() || _applyingLayout) + { + return nullptr; + } + const auto newId = GenerateUuid(); + WriteToLeader(FrameNewSplit(sourcePaneId, newId, vertical)); + _nextPaneId = newId; + return winrt::make(this, newId); + } + + ITerminalConnection HtmSession::CreateFollowerForUserTab() + { + if (!_leader || _applyingLayout) + { + return nullptr; + } + const auto tabId = GenerateUuid(); + const auto paneId = GenerateUuid(); + WriteToLeader(FrameNewTab(tabId, paneId)); + _nextPaneId = paneId; + return winrt::make(this, paneId); + } + + bool HtmSession::HandleUserClose(const ITerminalConnection& connection) + { + if (!IsHtmConnection(connection) || _suppressClosePackets) + { + return false; + } + if (const auto follower{ connection.try_as() }) + { + // Follower Close() sends CLIENT_CLOSE_PANE. + return true; + } + if (const auto leader{ connection.try_as() }) + { + if (leader.get() == _leader && !leader->PaneId().empty()) + { + WriteToLeader(FrameClientClosePane(leader->PaneId())); + return true; + } + } + return false; + } + + std::string HtmSession::_firstPaneId(const JsonObject& state, const winrt::hstring& paneOrSplit) + { + const auto paneKey = paneOrSplit; + if (state.HasKey(L"panes")) + { + if (const auto panes{ state.GetNamedObject(L"panes", nullptr) }) + { + if (panes.HasKey(paneKey)) + { + return til::u16u8(paneKey); + } + } + } + if (!state.HasKey(L"splits")) + { + return til::u16u8(paneKey); + } + const auto splits = state.GetNamedObject(L"splits", nullptr); + if (!splits || !splits.HasKey(paneKey)) + { + return til::u16u8(paneKey); + } + const auto split = splits.GetNamedObject(paneKey); + const auto children = split.GetNamedArray(L"panesOrSplits", nullptr); + if (!children || children.Size() == 0) + { + return til::u16u8(paneKey); + } + return _firstPaneId(state, children.GetAt(0).GetString()); + } + + void HtmSession::_createSplits(const JsonObject& state, const JsonObject& split) + { + if (!split) + { + return; + } + const auto children = split.GetNamedArray(L"panesOrSplits", nullptr); + const bool vertical = split.GetNamedBoolean(L"vertical", false); + if (!children) + { + return; + } + for (uint32_t i = 1; i < children.Size(); ++i) + { + const auto sourceId = _firstPaneId(state, children.GetAt(i - 1).GetString()); + const auto newId = _firstPaneId(state, children.GetAt(i).GetString()); + _nextPaneId = newId; + auto follower = winrt::make(this, newId); + _page->_HtmSplitExisting(sourceId, follower, vertical); + _initializedPanes.insert(newId); + } + for (uint32_t i = 0; i < children.Size(); ++i) + { + const auto id = children.GetAt(i).GetString(); + if (state.HasKey(L"splits")) + { + const auto splits = state.GetNamedObject(L"splits"); + if (splits.HasKey(id)) + { + _createSplits(state, splits.GetNamedObject(id)); + } + } + } + } + + void HtmSession::_applyInitState(const std::string& json) + { + JsonObject state{ nullptr }; + try + { + state = JsonObject::Parse(winrt::hstring{ til::u8u16(json) }); + } + catch (...) + { + return; + } + if (!state) + { + return; + } + + const auto dispatcher = _page->Dispatcher(); + dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, state]() { + try + { + _applyingLayout = true; + _initializedPanes.clear(); + + std::vector> tabs; + if (state.HasKey(L"tabs")) + { + const auto tabMap = state.GetNamedObject(L"tabs"); + for (const auto& item : tabMap) + { + const auto tab = item.Value().GetObject(); + const auto order = static_cast(tab.GetNamedNumber(L"order", 0)); + tabs.emplace_back(order, tab); + } + } + std::sort(tabs.begin(), tabs.end(), [](const auto& a, const auto& b) { return a.first < b.first; }); + + for (size_t i = 0; i < tabs.size(); ++i) + { + const auto& tab = tabs[i].second; + const auto root = tab.GetNamedString(L"paneOrSplit"); + const auto firstPane = _firstPaneId(state, root); + if (i == 0) + { + if (_leader) + { + _leader->SetPaneId(firstPane); + } + _initializedPanes.insert(firstPane); + } + else + { + _nextPaneId = firstPane; + auto follower = winrt::make(this, firstPane); + _page->_HtmNewTab(follower); + _initializedPanes.insert(firstPane); + } + if (state.HasKey(L"splits")) + { + const auto splits = state.GetNamedObject(L"splits"); + if (splits.HasKey(root)) + { + _createSplits(state, splits.GetNamedObject(root)); + } + } + } + } + catch (...) + { + } + _applyingLayout = false; + }); + } + + void HtmSession::_appendToPane(const std::string& paneId, std::string_view utf8) + { + const std::string data{ utf8 }; + const auto dispatcher = _page->Dispatcher(); + dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, paneId, data]() { + if (_leader && _leader->PaneId() == paneId) + { + _leader->InjectOutput(data); + return; + } + std::lock_guard lock{ _mutex }; + if (const auto it = _followers.find(paneId); it != _followers.end() && it->second) + { + it->second->InjectOutput(data); + } + }); + } + + void HtmSession::_closePaneFromServer(const std::string& paneId) + { + const auto dispatcher = _page->Dispatcher(); + dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, paneId]() { + _suppressClosePackets = true; + _page->_HtmClosePane(paneId); + _suppressClosePackets = false; + std::lock_guard lock{ _mutex }; + _followers.erase(paneId); + }); + } + + void HtmSession::_exitHtmMode(bool /*fromServer*/) + { + const auto dispatcher = _page->Dispatcher(); + dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this]() { + _suppressClosePackets = true; + std::vector ids; + { + std::lock_guard lock{ _mutex }; + for (const auto& [id, _] : _followers) + { + ids.push_back(id); + } + } + for (const auto& id : ids) + { + _page->_HtmClosePane(id); + } + { + std::lock_guard lock{ _mutex }; + _followers.clear(); + _leader = nullptr; + } + _suppressClosePackets = false; + }); + } +} diff --git a/src/cascadia/TerminalApp/HtmSession.h b/src/cascadia/TerminalApp/HtmSession.h new file mode 100644 index 00000000000..a6efb08c752 --- /dev/null +++ b/src/cascadia/TerminalApp/HtmSession.h @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#pragma once + +#include "HtmConnections.h" + +#include +#include +#include + +#include + +namespace winrt::TerminalApp::implementation +{ + struct TerminalPage; + + class HtmSession + { + public: + explicit HtmSession(TerminalPage* page); + + void AttachLeader(HtmLeaderConnection* leader); + void DetachLeader(HtmLeaderConnection* leader); + bool IsActive() const noexcept; + bool IsHtmConnection(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection) const; + + void RegisterFollower(HtmFollowerConnection* follower); + void UnregisterFollower(HtmFollowerConnection* follower); + + void WriteToLeader(std::string_view packet); + void HandlePacket(char header, std::string_view payload); + void HandleExitSequence(); + + winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection CreateFollowerForUserSplit(const std::string& sourcePaneId, bool vertical); + winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection CreateFollowerForUserTab(); + bool HandleUserClose(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection); + + std::string GenerateUuid(); + + private: + void _applyInitState(const std::string& json); + void _appendToPane(const std::string& paneId, std::string_view utf8); + void _closePaneFromServer(const std::string& paneId); + void _exitHtmMode(bool fromServer); + std::string _firstPaneId(const winrt::Windows::Data::Json::JsonObject& state, const winrt::hstring& paneOrSplit); + void _createSplits(const winrt::Windows::Data::Json::JsonObject& state, const winrt::Windows::Data::Json::JsonObject& split); + + TerminalPage* _page; + HtmLeaderConnection* _leader{ nullptr }; + std::mutex _mutex; + std::unordered_map _followers; + std::unordered_set _initializedPanes; + std::string _nextPaneId; + bool _applyingLayout{ false }; + bool _suppressClosePackets{ false }; + }; +} diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index 72c5e0fb676..237b41eaa5e 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -88,6 +88,18 @@ namespace winrt::TerminalApp::implementation // This call to _MakePane won't return nullptr, we already checked that // case above with the _maybeElevate call. + if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) + { + const auto focusedConn = _HtmFocusedConnection(); + if (!_HtmPaneIdFromConnection(focusedConn).empty()) + { + if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) + { + _CreateNewTabFromPane(_MakePane(newContentArgs, nullptr, follower)); + return S_OK; + } + } + } _CreateNewTabFromPane(_MakePane(newContentArgs, nullptr)); return S_OK; } diff --git a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj index 371dbd1746e..64b885de1be 100644 --- a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj +++ b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj @@ -140,6 +140,9 @@ ShortcutActionDispatch.idl + + + AppKeyBindings.idl @@ -247,6 +250,8 @@ + + Create diff --git a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters index 661065d9ac3..ff469b5f947 100644 --- a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters +++ b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters @@ -20,6 +20,8 @@ + + commandPalette @@ -45,6 +47,9 @@ + + + commandPalette diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 7b7f771012d..50263f04ff3 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -16,6 +16,8 @@ #include "../TerminalSettingsAppAdapterLib/TerminalSettings.h" #include "App.h" #include "DebugTapConnection.h" +#include "HtmConnections.h" +#include "HtmSession.h" #include "MarkdownPaneContent.h" #include "Remoting.h" #include "ScratchpadContent.h" @@ -227,6 +229,10 @@ namespace winrt::TerminalApp::implementation { InitializeComponent(); _WindowProperties.PropertyChanged({ get_weak(), &TerminalPage::_windowPropertyChanged }); + if (Feature_HtmIntegration::IsEnabled()) + { + _htmSession = std::make_unique(this); + } } // Method Description: @@ -1591,7 +1597,30 @@ namespace winrt::TerminalApp::implementation else { auto settingsInternal{ winrt::get_self(settings) }; - const auto environment = settingsInternal->EnvironmentVariables(); + auto environment = settingsInternal->EnvironmentVariables(); + Windows::Foundation::Collections::IMapView environmentView = environment; + if (Feature_HtmIntegration::IsEnabled() && environment && environment.HasKey(L"HTM_BIN_DIR")) + { + auto envMap = winrt::single_threaded_map(); + for (const auto& [k, v] : environment) + { + envMap.Insert(k, v); + } + const auto bin = envMap.Lookup(L"HTM_BIN_DIR"); + hstring path; + if (envMap.HasKey(L"PATH")) + { + path = envMap.Lookup(L"PATH"); + } + if (path.empty()) + { + wchar_t systemPath[32767]{}; + GetEnvironmentVariableW(L"PATH", systemPath, 32767); + path = systemPath; + } + envMap.Insert(L"PATH", bin + L";" + path); + environmentView = envMap.GetView(); + } // Update the path to be relative to whatever our CWD is. // @@ -1615,7 +1644,7 @@ namespace winrt::TerminalApp::implementation settings.StartingTitle(), settingsInternal->ReloadEnvironmentVariables(), _WindowProperties.VirtualEnvVars(), - environment, + environmentView, settings.InitialRows(), settings.InitialCols(), winrt::guid(), @@ -1643,6 +1672,12 @@ namespace winrt::TerminalApp::implementation connection.Initialize(valueSet); + if (Feature_HtmIntegration::IsEnabled() && _htmSession && + connection.try_as()) + { + connection = winrt::make(connection, _htmSession.get()); + } + TraceLoggingWrite( g_hTerminalAppProvider, "ConnectionCreated", @@ -6222,4 +6257,115 @@ namespace winrt::TerminalApp::implementation return profileMenuItemFlyout; } + + std::string TerminalPage::_HtmPaneIdFromConnection(const TerminalConnection::ITerminalConnection& connection) const + { + if (!connection) + { + return {}; + } + if (const auto leader{ connection.try_as() }) + { + return leader->PaneId(); + } + if (const auto follower{ connection.try_as() }) + { + return follower->PaneId(); + } + return {}; + } + + TerminalConnection::ITerminalConnection TerminalPage::_HtmFocusedConnection() const + { + if (const auto tab{ _GetFocusedTabImpl() }) + { + if (const auto control{ tab->GetActiveTerminalControl() }) + { + return control.Connection(); + } + } + return nullptr; + } + + std::shared_ptr TerminalPage::_HtmFindPane(const std::string& paneId) const + { + if (paneId.empty()) + { + return nullptr; + } + for (const auto& tab : _tabs) + { + if (const auto tabImpl{ _GetTabImpl(tab) }) + { + if (const auto pane{ tabImpl->GetRootPane()->_FindPane([&](const auto& candidate) { + const auto control = candidate->GetTerminalControl(); + if (!control) + { + return false; + } + return _HtmPaneIdFromConnection(control.Connection()) == paneId; + }) }) + { + return pane; + } + } + } + return nullptr; + } + + void TerminalPage::_HtmSplitExisting(const std::string& sourcePaneId, + TerminalConnection::ITerminalConnection follower, + bool vertical) + { + auto sourcePane = _HtmFindPane(sourcePaneId); + winrt::com_ptr tabImpl; + if (sourcePane) + { + for (const auto& tab : _tabs) + { + if (const auto candidate{ _GetTabImpl(tab) }) + { + if (candidate->GetRootPane()->_FindPane([&](const auto& p) { return p == sourcePane; })) + { + tabImpl = candidate; + break; + } + } + } + sourcePane->SetActive(); + } + else + { + tabImpl = _GetFocusedTabImpl(); + } + auto newPane = _MakeTerminalPane(nullptr, tabImpl ? *tabImpl : nullptr, follower); + const auto direction = vertical ? SplitDirection::Right : SplitDirection::Down; + _SplitPane(tabImpl, direction, 0.5f, newPane); + } + + void TerminalPage::_HtmNewTab(TerminalConnection::ITerminalConnection follower) + { + winrt::TerminalApp::Tab sourceTab{ nullptr }; + if (const auto focused{ _GetFocusedTabImpl() }) + { + sourceTab = *focused; + } + auto newPane = _MakeTerminalPane(nullptr, sourceTab, follower); + _CreateNewTabFromPane(newPane); + } + + void TerminalPage::_HtmClosePane(const std::string& paneId) + { + if (auto pane{ _HtmFindPane(paneId) }) + { + if (const auto control{ pane->GetTerminalControl() }) + { + if (const auto follower{ control.Connection().try_as() }) + { + follower->SetSuppressClosePacket(true); + } + } + _HandleClosePaneRequested(pane); + } + } } diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index a3e76cb6027..991a28c1d12 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -18,6 +18,7 @@ #include "WindowListRequest.g.h" #include "Toast.h" +#include "HtmSession.h" #include "WindowsPackageManagerFactory.h" #define DECLARE_ACTION_HANDLER(action) void _Handle##action(const IInspectable& sender, const Microsoft::Terminal::Settings::Model::ActionEventArgs& args); @@ -143,6 +144,8 @@ namespace winrt::TerminalApp::implementation struct TerminalPage : TerminalPageT { + friend class HtmSession; + public: TerminalPage(TerminalApp::WindowProperties properties, const TerminalApp::ContentManager& manager); @@ -288,6 +291,7 @@ namespace winrt::TerminalApp::implementation winrt::TerminalApp::ColorPickupFlyout _tabColorPicker{ nullptr }; Microsoft::Terminal::Settings::Model::CascadiaSettings _settings{ nullptr }; + std::unique_ptr _htmSession; Windows::Foundation::Collections::IObservableVector _tabs; Windows::Foundation::Collections::IObservableVector _mruTabs; @@ -384,6 +388,13 @@ namespace winrt::TerminalApp::implementation winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection _duplicateConnectionForRestart(const TerminalApp::TerminalPaneContent& paneContent); void _restartPaneConnection(const TerminalApp::TerminalPaneContent&, const winrt::Windows::Foundation::IInspectable&); + void _HtmSplitExisting(const std::string& sourcePaneId, winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower, bool vertical); + void _HtmNewTab(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower); + void _HtmClosePane(const std::string& paneId); + std::string _HtmPaneIdFromConnection(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection) const; + winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection _HtmFocusedConnection() const; + std::shared_ptr _HtmFindPane(const std::string& paneId) const; + void _OpenNewWindow(const Microsoft::Terminal::Settings::Model::INewContentArgs& contentArgs); void _OpenWorkspaceWindow(const winrt::hstring name); diff --git a/src/cascadia/ut_app/HtmProtocolTests.cpp b/src/cascadia/ut_app/HtmProtocolTests.cpp new file mode 100644 index 00000000000..e5ee034140e --- /dev/null +++ b/src/cascadia/ut_app/HtmProtocolTests.cpp @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#include "precomp.h" +#include "../TerminalApp/HtmProtocol.h" + +using namespace WEX::Logging; +using namespace WEX::TestExecution; +using namespace WEX::Common; +using namespace Microsoft::Terminal::Htm; + +namespace TerminalAppUnitTests +{ + class HtmProtocolTests + { + TEST_CLASS(HtmProtocolTests); + + TEST_METHOD(EncodeLengthRoundTrip) + { + const auto encoded = EncodeLength(1); + VERIFY_ARE_EQUAL(8u, encoded.size()); + VERIFY_ARE_EQUAL(1, DecodeLength(encoded)); + VERIFY_ARE_EQUAL(128, DecodeLength(EncodeLength(128))); + } + + TEST_METHOD(SessionEndIsOneByte) + { + std::string buffer; + buffer.push_back(SessionEnd); + buffer.append("leftover"); + const auto [packets, rest] = ParsePackets(buffer); + VERIFY_ARE_EQUAL(1u, packets.size()); + VERIFY_ARE_EQUAL(SessionEnd, packets[0].header); + VERIFY_ARE_EQUAL("leftover", rest); + } + + TEST_METHOD(ParseFramedPacket) + { + const auto framed = FramePacket(InitState, R"({"tabs":{}})"); + const auto [packets, rest] = ParsePackets(framed); + VERIFY_ARE_EQUAL(1u, packets.size()); + VERIFY_ARE_EQUAL(InitState, packets[0].header); + VERIFY_ARE_EQUAL(R"({"tabs":{}})", packets[0].payload); + VERIFY_IS_TRUE(rest.empty()); + } + + TEST_METHOD(PartialPacketStaysInBuffer) + { + auto framed = FramePacket(DebugLog, "abcd"); + framed.resize(5); // header + 4 of 8 length chars + const auto [packets, rest] = ParsePackets(framed); + VERIFY_IS_TRUE(packets.empty()); + VERIFY_ARE_EQUAL(framed, rest); + } + + TEST_METHOD(ConsumeInitAcrossChunks) + { + const auto first = ConsumeInitPayload("", "hello\x1b[#"); + VERIFY_IS_FALSE(first.matched); + VERIFY_ARE_EQUAL("hello", first.prefix); + VERIFY_ARE_EQUAL("\x1b[#", first.pending); + + const auto second = ConsumeInitPayload(first.pending, "##qREST"); + VERIFY_IS_TRUE(second.matched); + VERIFY_ARE_EQUAL("REST", second.remainder); + } + + TEST_METHOD(InsertKeysFrameContainsUuidAndPayload) + { + const auto pane = "12345678-1234-1234-1234-1234567890ab"; + VERIFY_ARE_EQUAL(UuidLength, pane.size()); + const auto packet = FrameInsertKeys(pane, "hi"); + const auto [packets, rest] = ParsePackets(packet); + VERIFY_ARE_EQUAL(1u, packets.size()); + VERIFY_ARE_EQUAL(InsertKeys, packets[0].header); + VERIFY_ARE_EQUAL(pane, packets[0].payload.substr(0, UuidLength)); + VERIFY_ARE_EQUAL("hi", Base64Decode(packets[0].payload.substr(UuidLength))); + } + }; +} diff --git a/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj b/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj index 1eba2400bb6..2212bec3773 100644 --- a/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj +++ b/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj @@ -23,6 +23,7 @@ + Create diff --git a/src/features.xml b/src/features.xml index ee6c5be6399..86934e907aa 100644 --- a/src/features.xml +++ b/src/features.xml @@ -187,6 +187,16 @@ + + Feature_HtmIntegration + Detect HTM init sequences and map Windows Terminal tabs/panes onto htmd + AlwaysEnabled + + + WindowsInbox + + + Feature_WarnOnInvalidSettingsMediaResources Controls whether Terminal should display a warning dialog when icon, backgroundImage, shader, etc. could not be found. From e5b12edeaac7bd7b6ccf2d058a2c3e9bd9ebf52a Mon Sep 17 00:00:00 2001 From: Jason Gauci Date: Wed, 2 Sep 2026 17:45:34 -0500 Subject: [PATCH 2/9] Update HTM integration for tmux control mode Replace the legacy framed HTM protocol handling with tmux -CC control-mode parsing, command replies, pane output routing, and DCS lifecycle handling. Route Windows Terminal split, tab, resize, keyboard, detach, and shutdown actions through HTM followers while preserving normal ConPTY behavior for non-HTM profiles. Synchronize pending follower assignment so asynchronous pane notifications cannot race tab creation. Add protocol helper coverage and a live concurrent htm/htmd stress test that exercises refresh-client, split-window, display-message, and clean server shutdown. --- .../TerminalApp/AppActionHandlers.cpp | 59 +- src/cascadia/TerminalApp/HtmConnections.cpp | 128 ++-- src/cascadia/TerminalApp/HtmConnections.h | 8 +- src/cascadia/TerminalApp/HtmProtocol.h | 29 + src/cascadia/TerminalApp/HtmSession.cpp | 423 +++--------- src/cascadia/TerminalApp/HtmSession.h | 24 +- src/cascadia/TerminalApp/TerminalPage.cpp | 21 +- .../TerminalConnection/ConptyConnection.cpp | 1 - src/cascadia/ut_app/HtmProtocolTests.cpp | 643 +++++++++++++++++- 9 files changed, 921 insertions(+), 415 deletions(-) diff --git a/src/cascadia/TerminalApp/AppActionHandlers.cpp b/src/cascadia/TerminalApp/AppActionHandlers.cpp index 2f21f61d8ac..53701985e3e 100644 --- a/src/cascadia/TerminalApp/AppActionHandlers.cpp +++ b/src/cascadia/TerminalApp/AppActionHandlers.cpp @@ -29,8 +29,8 @@ namespace winrt using IInspectable = Windows::Foundation::IInspectable; } -namespace winrt::TerminalApp::implementation -{ +namespace winrt::TerminalApp::implementation +{ TermControl TerminalPage::_senderOrActiveControl(const IInspectable& sender) { if (sender) @@ -265,9 +265,9 @@ namespace winrt::TerminalApp::implementation return false; } - void TerminalPage::_HandleSplitPane(const IInspectable& sender, - const ActionEventArgs& args) - { + void TerminalPage::_HandleSplitPane(const IInspectable& sender, + const ActionEventArgs& args) + { if (args == nullptr) { args.Handled(false); @@ -476,15 +476,30 @@ namespace winrt::TerminalApp::implementation args.Handled(true); } - void TerminalPage::_HandleNewTab(const IInspectable& /*sender*/, - const ActionEventArgs& args) - { - if (args == nullptr) - { - LOG_IF_FAILED(_OpenNewTab(nullptr)); - args.Handled(true); - } - else if (const auto& realArgs = args.ActionArgs().try_as()) + void TerminalPage::_HandleNewTab(const IInspectable& /*sender*/, + const ActionEventArgs& args) + { + const auto realArgs = args ? args.ActionArgs().try_as() : nullptr; + if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) + { + const auto focusedConn = _HtmFocusedConnection(); + if (!_HtmPaneIdFromConnection(focusedConn).empty()) + { + if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) + { + _CreateNewTabFromPane(_MakePane(realArgs ? realArgs.ContentArgs() : nullptr, nullptr, follower)); + args.Handled(true); + return; + } + } + } + + if (args == nullptr) + { + LOG_IF_FAILED(_OpenNewTab(nullptr)); + return; + } + else if (realArgs) { if (_shouldBailForInvalidProfileIndex(_settings, realArgs.ContentArgs())) { @@ -492,21 +507,7 @@ namespace winrt::TerminalApp::implementation return; } - if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) - { - const auto focusedConn = _HtmFocusedConnection(); - if (!_HtmPaneIdFromConnection(focusedConn).empty()) - { - if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) - { - _CreateNewTabFromPane(_MakePane(realArgs.ContentArgs(), nullptr, follower)); - args.Handled(true); - return; - } - } - } - - LOG_IF_FAILED(_OpenNewTab(realArgs.ContentArgs())); + LOG_IF_FAILED(_OpenNewTab(realArgs.ContentArgs())); args.Handled(true); } } diff --git a/src/cascadia/TerminalApp/HtmConnections.cpp b/src/cascadia/TerminalApp/HtmConnections.cpp index ae3aef262a2..541f4f41982 100644 --- a/src/cascadia/TerminalApp/HtmConnections.cpp +++ b/src/cascadia/TerminalApp/HtmConnections.cpp @@ -11,7 +11,8 @@ using namespace ::Microsoft::Terminal::Htm; namespace winrt::TerminalApp::implementation { HtmLeaderConnection::HtmLeaderConnection(ITerminalConnection wrapped, HtmSession* session) : - _wrapped{ std::move(wrapped) }, + _wrapped{ wrapped }, + _sessionId{ wrapped.SessionId() }, _session{ session } { _outputRevoker = _wrapped.TerminalOutput(winrt::auto_revoke, { get_weak(), &HtmLeaderConnection::_OutputHandler }); @@ -38,7 +39,24 @@ namespace winrt::TerminalApp::implementation if (_htmMode) { const auto utf8 = til::u16u8(winrt_array_to_wstring_view(data)); - WriteRaw(FrameInsertDebugKeys(utf8)); + if (utf8 == "\x1b[I" || utf8 == "\x1b[O") + { + return; + } + // With VT input enabled, Windows Terminal reports Escape as an + // enhanced-key sequence whose first codepoint is 27. Plain VT + // input still arrives as the single ESC byte. + if (utf8 == "\x1b" || utf8.starts_with("\x1b[27;")) + { + _session->WriteToLeader("detach-client"); + return; + } + if (utf8 == "x" || utf8.starts_with("\x1b[88;0;120;1;")) + { + _session->WriteToLeader("kill-server"); + return; + } + _session->SendKeys(_paneId, utf8); return; } _wrapped.WriteInput(data); @@ -49,12 +67,13 @@ namespace winrt::TerminalApp::implementation _wrapped.Resize(rows, columns); if (_htmMode && !_paneId.empty()) { - WriteRaw(FrameResizePane(_paneId, static_cast(columns), static_cast(rows))); + _session->WriteToLeader("refresh-client -C " + std::to_string(columns) + "x" + std::to_string(rows)); } } void HtmLeaderConnection::Close() { + _closed = true; if (_session && _htmMode) { _session->DetachLeader(this); @@ -70,17 +89,25 @@ namespace winrt::TerminalApp::implementation winrt::guid HtmLeaderConnection::SessionId() const noexcept { - return _wrapped ? _wrapped.SessionId() : winrt::guid{}; + return _sessionId; } ConnectionState HtmLeaderConnection::State() const noexcept { - return _wrapped ? _wrapped.State() : ConnectionState::Closed; + return _closed ? ConnectionState::Closed : ConnectionState::Connected; } void HtmLeaderConnection::WriteRaw(std::string_view bytes) { - if (!_wrapped || bytes.empty()) + if (bytes.empty()) + { + return; + } + // Pane input, resizes, and app actions can arrive on different UI and + // connection threads. Keep each HTM frame in one ConPTY write so a + // resize cannot splice itself into a key or split packet. + std::lock_guard lock{ _writeMutex }; + if (!_wrapped) { return; } @@ -103,7 +130,7 @@ namespace winrt::TerminalApp::implementation const auto utf8 = til::u16u8(winrt_array_to_wstring_view(str)); if (_htmMode) { - if (utf8.find(ExitSequence) != std::string::npos) + if (utf8.find(TmuxControlSt) != std::string::npos) { _htmMode = false; if (_session) @@ -116,56 +143,42 @@ namespace winrt::TerminalApp::implementation return; } - const auto result = ConsumeInitPayload(_pendingInit, utf8); - _pendingInit = result.pending; - if (!result.prefix.empty()) + _pendingInit.append(utf8); + const auto marker = _pendingInit.find(TmuxControlDcs); + if (marker == std::string::npos) { - const auto wide = til::u8u16(result.prefix); - TerminalOutput.raise(winrt_wstring_to_array_view(wide)); - } - if (result.matched) - { - _htmMode = true; - if (_session) + if (_pendingInit.size() > TmuxControlDcs.size()) { - _session->AttachLeader(this); - } - if (!result.remainder.empty()) - { - _ProcessHtmBytes(result.remainder); + const auto render = _pendingInit.substr(0, _pendingInit.size() - TmuxControlDcs.size()); + const auto wide = til::u8u16(render); + TerminalOutput.raise(winrt_wstring_to_array_view(wide)); + _pendingInit.erase(0, _pendingInit.size() - TmuxControlDcs.size()); } + return; } + const auto prefix = _pendingInit.substr(0, marker); + if (!prefix.empty()) + { + const auto wide = til::u8u16(prefix); + TerminalOutput.raise(winrt_wstring_to_array_view(wide)); + } + const auto remainder = _pendingInit.substr(marker + TmuxControlDcs.size()); + _pendingInit.clear(); + _htmMode = true; + if (_session) _session->AttachLeader(this); + if (!remainder.empty()) _ProcessHtmBytes(remainder); } void HtmLeaderConnection::_ProcessHtmBytes(std::string_view utf8) { _htmBuffer.append(utf8); - auto [packets, rest] = ParsePackets(_htmBuffer); - _htmBuffer = std::move(rest); - for (const auto& packet : packets) + size_t newline = 0; + while ((newline = _htmBuffer.find('\n')) != std::string::npos) { - if (packet.invalidLength) - { - _htmMode = false; - if (_session) - { - _session->HandleExitSequence(); - } - return; - } - if (packet.header == SessionEnd) - { - _htmMode = false; - if (_session) - { - _session->HandlePacket(packet.header, packet.payload); - } - return; - } - if (_session) - { - _session->HandlePacket(packet.header, packet.payload); - } + auto line = _htmBuffer.substr(0, newline); + _htmBuffer.erase(0, newline + 1); + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (_session) _session->HandleLine(line); } } @@ -182,6 +195,10 @@ namespace winrt::TerminalApp::implementation if (_session) { _session->RegisterFollower(this); + if (!_paneId.empty()) + { + Resize(_rows, _cols); + } } } @@ -192,16 +209,25 @@ namespace winrt::TerminalApp::implementation return; } const auto utf8 = til::u16u8(winrt_array_to_wstring_view(data)); - _session->WriteToLeader(FrameInsertKeys(_paneId, utf8)); + _session->SendKeys(_paneId, utf8); } void HtmFollowerConnection::Resize(uint32_t rows, uint32_t columns) { _rows = rows; _cols = columns; - if (_session) + if (_session && !_paneId.empty()) + { + _session->WriteToLeader("resize-pane -t " + _paneId + " -x " + std::to_string(columns) + " -y " + std::to_string(rows)); + } + } + + void HtmFollowerConnection::SetPaneId(std::string paneId) + { + _paneId = std::move(paneId); + if (_started && !_paneId.empty()) { - _session->WriteToLeader(FrameResizePane(_paneId, static_cast(columns), static_cast(rows))); + Resize(_rows, _cols); } } @@ -211,7 +237,7 @@ namespace winrt::TerminalApp::implementation { if (!_suppressClosePacket) { - _session->WriteToLeader(FrameClientClosePane(_paneId)); + _session->WriteToLeader("kill-pane -t " + _paneId); } _session->UnregisterFollower(this); } diff --git a/src/cascadia/TerminalApp/HtmConnections.h b/src/cascadia/TerminalApp/HtmConnections.h index c76bdf25d1d..1ba45e55700 100644 --- a/src/cascadia/TerminalApp/HtmConnections.h +++ b/src/cascadia/TerminalApp/HtmConnections.h @@ -6,7 +6,9 @@ #include "HtmProtocol.h" #include -#include +#include + +#include namespace winrt::TerminalApp::implementation { @@ -41,6 +43,7 @@ namespace winrt::TerminalApp::implementation void _ProcessHtmBytes(std::string_view utf8); winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection _wrapped{ nullptr }; + winrt::guid _sessionId{}; winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection::TerminalOutput_revoker _outputRevoker; winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection::StateChanged_revoker _stateChangedRevoker; HtmSession* _session{ nullptr }; @@ -48,6 +51,8 @@ namespace winrt::TerminalApp::implementation std::string _pendingInit; std::string _htmBuffer; std::string _paneId; + std::mutex _writeMutex; + bool _closed{ false }; }; class HtmFollowerConnection : public winrt::implements @@ -68,6 +73,7 @@ namespace winrt::TerminalApp::implementation } const std::string& PaneId() const noexcept { return _paneId; } + void SetPaneId(std::string paneId); void InjectOutput(std::string_view utf8); void SetSuppressClosePacket(bool value) noexcept { _suppressClosePacket = value; } diff --git a/src/cascadia/TerminalApp/HtmProtocol.h b/src/cascadia/TerminalApp/HtmProtocol.h index bc5cf9f2849..18b81fd1282 100644 --- a/src/cascadia/TerminalApp/HtmProtocol.h +++ b/src/cascadia/TerminalApp/HtmProtocol.h @@ -17,6 +17,35 @@ namespace Microsoft::Terminal::Htm { + // HTM now uses tmux control mode. These are terminal-facing markers; the + // bytes after DCS are ordinary newline-delimited tmux control records. + inline constexpr std::string_view TmuxControlDcs{ "\x1bP1000p" }; + inline constexpr std::string_view TmuxControlSt{ "\x1b\\" }; + + inline std::string UnescapeControlOutput(std::string_view input) + { + std::string result; + result.reserve(input.size()); + for (size_t i = 0; i < input.size(); ++i) + { + if (input[i] == '\\' && i + 3 < input.size() && + input[i + 1] >= '0' && input[i + 1] <= '7' && + input[i + 2] >= '0' && input[i + 2] <= '7' && + input[i + 3] >= '0' && input[i + 3] <= '7') + { + result.push_back(static_cast(((input[i + 1] - '0') << 6) | + ((input[i + 2] - '0') << 3) | + (input[i + 3] - '0'))); + i += 3; + } + else + { + result.push_back(input[i]); + } + } + return result; + } + inline constexpr char InsertKeys = '1'; inline constexpr char InitState = '2'; inline constexpr char ClientClosePane = '3'; diff --git a/src/cascadia/TerminalApp/HtmSession.cpp b/src/cascadia/TerminalApp/HtmSession.cpp index 959cc988b5c..9040453a559 100644 --- a/src/cascadia/TerminalApp/HtmSession.cpp +++ b/src/cascadia/TerminalApp/HtmSession.cpp @@ -6,389 +6,190 @@ #include "HtmConnections.h" #include "TerminalPage.h" -#include "../../types/inc/utils.hpp" - -#include -#include - using namespace winrt::Microsoft::Terminal::TerminalConnection; -using namespace winrt::Windows::Data::Json; using namespace ::Microsoft::Terminal::Htm; -using namespace ::Microsoft::Console; namespace winrt::TerminalApp::implementation { - HtmSession::HtmSession(TerminalPage* page) : - _page{ page } - { - } + HtmSession::HtmSession(TerminalPage* page) : _page{ page } {} void HtmSession::AttachLeader(HtmLeaderConnection* leader) { - std::lock_guard lock{ _mutex }; - _leader = leader; - } - - void HtmSession::DetachLeader(HtmLeaderConnection* leader) - { - bool wasLeader = false; - { - std::lock_guard lock{ _mutex }; - if (_leader == leader) + { std::lock_guard lock{ _mutex }; _leader = leader; } + // DCS is detected inside ConptyConnection's output callback. Queue the + // first command so that callback can return before we call WriteInput + // on the same connection. + const auto weakLeader = leader->get_weak(); + _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, weakLeader]() { + if (const auto leader = weakLeader.get(); leader && _leader == leader.get() && + leader->State() == ConnectionState::Connected) { - _leader = nullptr; - wasLeader = true; + WriteToLeader("refresh-client -C 80x24"); } - } - if (wasLeader) - { - _exitHtmMode(true); - } + }); } - bool HtmSession::IsActive() const noexcept + void HtmSession::DetachLeader(HtmLeaderConnection* leader) { - return _leader != nullptr; + if (_leader == leader) { _leader = nullptr; _exitHtmMode(); } } + bool HtmSession::IsActive() const noexcept { return _leader != nullptr; } + bool HtmSession::IsHtmConnection(const ITerminalConnection& connection) const { - if (!connection) - { - return false; - } - if (const auto leader{ connection.try_as() }) - { - return leader.get() == _leader; - } - if (const auto follower{ connection.try_as() }) - { - return _followers.contains(follower->PaneId()); - } + if (const auto leader{ connection.try_as() }) return leader.get() == _leader; + if (const auto follower{ connection.try_as() }) return _followers.contains(follower->PaneId()); return false; } void HtmSession::RegisterFollower(HtmFollowerConnection* follower) { - if (!follower) - { - return; - } - std::lock_guard lock{ _mutex }; - _followers[follower->PaneId()] = follower; - _initializedPanes.insert(follower->PaneId()); + if (follower && !follower->PaneId().empty()) { std::lock_guard lock{ _mutex }; _followers[follower->PaneId()] = follower; } } void HtmSession::UnregisterFollower(HtmFollowerConnection* follower) { - if (!follower) - { - return; - } - std::lock_guard lock{ _mutex }; - _followers.erase(follower->PaneId()); + if (follower) { std::lock_guard lock{ _mutex }; _followers.erase(follower->PaneId()); } } - void HtmSession::WriteToLeader(std::string_view packet) + void HtmSession::WriteToLeader(std::string_view command) { if (_leader) { - _leader->WriteRaw(packet); + std::string line{ command }; + // A Windows console in line-input mode submits on CR, not LF. + // htmd accepts CR, LF, and CRLF as tmux command delimiters. + if (line.empty() || (line.back() != '\r' && line.back() != '\n')) line.push_back('\r'); + _leader->WriteRaw(line); } } - std::string HtmSession::GenerateUuid() - { - return til::u16u8(Utils::GuidToPlainString(Utils::CreateGuid())); - } - - void HtmSession::HandleExitSequence() + void HtmSession::SendKeys(std::string_view paneId, std::string_view utf8) { - _exitHtmMode(true); + if (paneId.empty()) return; + static constexpr char hex[] = "0123456789abcdef"; + std::string command{ "send-keys -H -t " }; + command += paneId; + for (unsigned char byte : utf8) + { + command += " 0x"; + command += hex[byte >> 4]; + command += hex[byte & 15]; + } + WriteToLeader(command); } - void HtmSession::HandlePacket(char header, std::string_view payload) + void HtmSession::HandleLine(std::string_view line) { - switch (header) + if (line.rfind("%output ", 0) == 0) { - case InitState: - _applyInitState(std::string{ payload }); - break; - case AppendToPane: - { - if (payload.size() < UuidLength) - { - break; - } - const auto paneId = std::string{ payload.substr(0, UuidLength) }; - const auto decoded = Base64Decode(payload.substr(UuidLength)); - _appendToPane(paneId, decoded); - break; - } - case DebugLog: - { - const auto decoded = Base64Decode(payload); - if (_leader) - { - _leader->InjectOutput(decoded); - } - break; + const auto first = line.find(' ', 8); + if (first != std::string_view::npos) _appendToPane(std::string{ line.substr(8, first - 8) }, UnescapeControlOutput(line.substr(first + 1))); + return; } - case ServerClosePane: + if (line.rfind("%window-pane-changed ", 0) == 0) { - if (payload.size() >= UuidLength) + const auto pos = line.rfind(' '); + if (pos != std::string_view::npos) { - _closePaneFromServer(std::string{ payload.substr(0, UuidLength) }); + const std::string paneId{ line.substr(pos + 1) }; + HtmFollowerConnection* follower = nullptr; + { + std::lock_guard lock{ _mutex }; + if (!_pendingFollowers.empty()) + { + follower = _pendingFollowers.front(); + _pendingFollowers.erase(_pendingFollowers.begin()); + _followers[paneId] = follower; + } + } + if (follower) + { + follower->SetPaneId(paneId); + } + else if (_leader && _leader->PaneId().empty()) + { + _leader->SetPaneId(paneId); + } } - break; - } - case SessionEnd: - _exitHtmMode(true); - break; - default: - break; - } - } - - ITerminalConnection HtmSession::CreateFollowerForUserSplit(const std::string& sourcePaneId, bool vertical) - { - if (!_leader || sourcePaneId.empty() || _applyingLayout) - { - return nullptr; + return; } - const auto newId = GenerateUuid(); - WriteToLeader(FrameNewSplit(sourcePaneId, newId, vertical)); - _nextPaneId = newId; - return winrt::make(this, newId); + if (line.rfind("%begin ", 0) == 0) { _inReply = true; _replyLines.clear(); return; } + if (line.rfind("%end ", 0) == 0 || line.rfind("%error ", 0) == 0) { _finishReply(); return; } + if (line == "%exit") { _exitHtmMode(); return; } + if (_inReply) _replyLines.emplace_back(line); } - ITerminalConnection HtmSession::CreateFollowerForUserTab() + void HtmSession::_finishReply() { - if (!_leader || _applyingLayout) + _inReply = false; + if (_replyLines.empty()) return; + // -P -F '#{pane_id}' replies with exactly the new %pane identifier. + const auto id = _replyLines.front(); + if (id.empty() || id.front() != '%') return; + HtmFollowerConnection* follower = nullptr; { - return nullptr; + std::lock_guard lock{ _mutex }; + if (_pendingFollowers.empty()) return; + follower = _pendingFollowers.front(); + _pendingFollowers.erase(_pendingFollowers.begin()); } - const auto tabId = GenerateUuid(); - const auto paneId = GenerateUuid(); - WriteToLeader(FrameNewTab(tabId, paneId)); - _nextPaneId = paneId; - return winrt::make(this, paneId); + follower->SetPaneId(id); + std::lock_guard lock{ _mutex }; + _followers[id] = follower; } - bool HtmSession::HandleUserClose(const ITerminalConnection& connection) - { - if (!IsHtmConnection(connection) || _suppressClosePackets) - { - return false; - } - if (const auto follower{ connection.try_as() }) - { - // Follower Close() sends CLIENT_CLOSE_PANE. - return true; - } - if (const auto leader{ connection.try_as() }) - { - if (leader.get() == _leader && !leader->PaneId().empty()) - { - WriteToLeader(FrameClientClosePane(leader->PaneId())); - return true; - } - } - return false; - } + void HtmSession::HandleExitSequence() { _exitHtmMode(); } - std::string HtmSession::_firstPaneId(const JsonObject& state, const winrt::hstring& paneOrSplit) + ITerminalConnection HtmSession::CreateFollowerForUserSplit(const std::string& sourcePaneId, bool vertical) { - const auto paneKey = paneOrSplit; - if (state.HasKey(L"panes")) - { - if (const auto panes{ state.GetNamedObject(L"panes", nullptr) }) - { - if (panes.HasKey(paneKey)) - { - return til::u16u8(paneKey); - } - } - } - if (!state.HasKey(L"splits")) + if (!_leader || sourcePaneId.empty()) return nullptr; + auto follower = winrt::make_self(this, ""); { - return til::u16u8(paneKey); - } - const auto splits = state.GetNamedObject(L"splits", nullptr); - if (!splits || !splits.HasKey(paneKey)) - { - return til::u16u8(paneKey); - } - const auto split = splits.GetNamedObject(paneKey); - const auto children = split.GetNamedArray(L"panesOrSplits", nullptr); - if (!children || children.Size() == 0) - { - return til::u16u8(paneKey); + std::lock_guard lock{ _mutex }; + _pendingFollowers.push_back(follower.get()); } - return _firstPaneId(state, children.GetAt(0).GetString()); + WriteToLeader(std::string{ "split-window -P -F '#{pane_id}' -t " } + sourcePaneId + (vertical ? " -h" : " -v")); + return follower.as(); } - void HtmSession::_createSplits(const JsonObject& state, const JsonObject& split) + ITerminalConnection HtmSession::CreateFollowerForUserTab() { - if (!split) - { - return; - } - const auto children = split.GetNamedArray(L"panesOrSplits", nullptr); - const bool vertical = split.GetNamedBoolean(L"vertical", false); - if (!children) - { - return; - } - for (uint32_t i = 1; i < children.Size(); ++i) + if (!_leader) return nullptr; + auto follower = winrt::make_self(this, ""); { - const auto sourceId = _firstPaneId(state, children.GetAt(i - 1).GetString()); - const auto newId = _firstPaneId(state, children.GetAt(i).GetString()); - _nextPaneId = newId; - auto follower = winrt::make(this, newId); - _page->_HtmSplitExisting(sourceId, follower, vertical); - _initializedPanes.insert(newId); - } - for (uint32_t i = 0; i < children.Size(); ++i) - { - const auto id = children.GetAt(i).GetString(); - if (state.HasKey(L"splits")) - { - const auto splits = state.GetNamedObject(L"splits"); - if (splits.HasKey(id)) - { - _createSplits(state, splits.GetNamedObject(id)); - } - } + std::lock_guard lock{ _mutex }; + _pendingFollowers.push_back(follower.get()); } + WriteToLeader("new-window -P -F '#{pane_id}'"); + return follower.as(); } - void HtmSession::_applyInitState(const std::string& json) + bool HtmSession::HandleUserClose(const ITerminalConnection& connection) { - JsonObject state{ nullptr }; - try - { - state = JsonObject::Parse(winrt::hstring{ til::u8u16(json) }); - } - catch (...) - { - return; - } - if (!state) - { - return; - } - - const auto dispatcher = _page->Dispatcher(); - dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, state]() { - try - { - _applyingLayout = true; - _initializedPanes.clear(); - - std::vector> tabs; - if (state.HasKey(L"tabs")) - { - const auto tabMap = state.GetNamedObject(L"tabs"); - for (const auto& item : tabMap) - { - const auto tab = item.Value().GetObject(); - const auto order = static_cast(tab.GetNamedNumber(L"order", 0)); - tabs.emplace_back(order, tab); - } - } - std::sort(tabs.begin(), tabs.end(), [](const auto& a, const auto& b) { return a.first < b.first; }); - - for (size_t i = 0; i < tabs.size(); ++i) - { - const auto& tab = tabs[i].second; - const auto root = tab.GetNamedString(L"paneOrSplit"); - const auto firstPane = _firstPaneId(state, root); - if (i == 0) - { - if (_leader) - { - _leader->SetPaneId(firstPane); - } - _initializedPanes.insert(firstPane); - } - else - { - _nextPaneId = firstPane; - auto follower = winrt::make(this, firstPane); - _page->_HtmNewTab(follower); - _initializedPanes.insert(firstPane); - } - if (state.HasKey(L"splits")) - { - const auto splits = state.GetNamedObject(L"splits"); - if (splits.HasKey(root)) - { - _createSplits(state, splits.GetNamedObject(root)); - } - } - } - } - catch (...) - { - } - _applyingLayout = false; - }); + if (_suppressClosePackets || !IsHtmConnection(connection)) return false; + if (const auto follower{ connection.try_as() }) { WriteToLeader("kill-pane -t " + follower->PaneId()); return true; } + if (const auto leader{ connection.try_as() }) { WriteToLeader("kill-pane -t " + leader->PaneId()); return true; } + return false; } void HtmSession::_appendToPane(const std::string& paneId, std::string_view utf8) { const std::string data{ utf8 }; - const auto dispatcher = _page->Dispatcher(); - dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, paneId, data]() { - if (_leader && _leader->PaneId() == paneId) - { - _leader->InjectOutput(data); - return; - } + _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, paneId, data]() { + if (_leader && _leader->PaneId() == paneId) { _leader->InjectOutput(data); return; } std::lock_guard lock{ _mutex }; - if (const auto it = _followers.find(paneId); it != _followers.end() && it->second) - { - it->second->InjectOutput(data); - } + if (const auto it = _followers.find(paneId); it != _followers.end() && it->second) it->second->InjectOutput(data); }); } - void HtmSession::_closePaneFromServer(const std::string& paneId) + void HtmSession::_exitHtmMode() { - const auto dispatcher = _page->Dispatcher(); - dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, paneId]() { - _suppressClosePackets = true; - _page->_HtmClosePane(paneId); - _suppressClosePackets = false; - std::lock_guard lock{ _mutex }; - _followers.erase(paneId); - }); - } - - void HtmSession::_exitHtmMode(bool /*fromServer*/) - { - const auto dispatcher = _page->Dispatcher(); - dispatcher.RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this]() { - _suppressClosePackets = true; - std::vector ids; - { - std::lock_guard lock{ _mutex }; - for (const auto& [id, _] : _followers) - { - ids.push_back(id); - } - } - for (const auto& id : ids) - { - _page->_HtmClosePane(id); - } - { - std::lock_guard lock{ _mutex }; - _followers.clear(); - _leader = nullptr; - } - _suppressClosePackets = false; - }); + _suppressClosePackets = true; + std::lock_guard lock{ _mutex }; + _followers.clear(); + _pendingFollowers.clear(); + _suppressClosePackets = false; } } diff --git a/src/cascadia/TerminalApp/HtmSession.h b/src/cascadia/TerminalApp/HtmSession.h index a6efb08c752..37a7875fbbd 100644 --- a/src/cascadia/TerminalApp/HtmSession.h +++ b/src/cascadia/TerminalApp/HtmSession.h @@ -7,9 +7,7 @@ #include #include -#include - -#include +#include namespace winrt::TerminalApp::implementation { @@ -28,31 +26,27 @@ namespace winrt::TerminalApp::implementation void RegisterFollower(HtmFollowerConnection* follower); void UnregisterFollower(HtmFollowerConnection* follower); - void WriteToLeader(std::string_view packet); - void HandlePacket(char header, std::string_view payload); + void WriteToLeader(std::string_view command); + void HandleLine(std::string_view line); void HandleExitSequence(); + void SendKeys(std::string_view paneId, std::string_view utf8); winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection CreateFollowerForUserSplit(const std::string& sourcePaneId, bool vertical); winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection CreateFollowerForUserTab(); bool HandleUserClose(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection); - std::string GenerateUuid(); - private: - void _applyInitState(const std::string& json); void _appendToPane(const std::string& paneId, std::string_view utf8); - void _closePaneFromServer(const std::string& paneId); - void _exitHtmMode(bool fromServer); - std::string _firstPaneId(const winrt::Windows::Data::Json::JsonObject& state, const winrt::hstring& paneOrSplit); - void _createSplits(const winrt::Windows::Data::Json::JsonObject& state, const winrt::Windows::Data::Json::JsonObject& split); + void _exitHtmMode(); + void _finishReply(); TerminalPage* _page; HtmLeaderConnection* _leader{ nullptr }; std::mutex _mutex; std::unordered_map _followers; - std::unordered_set _initializedPanes; - std::string _nextPaneId; - bool _applyingLayout{ false }; + std::vector _pendingFollowers; + std::vector _replyLines; + bool _inReply{ false }; bool _suppressClosePackets{ false }; }; } diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 50263f04ff3..f9b95d7036a 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -1670,9 +1670,21 @@ namespace winrt::TerminalApp::implementation valueSet.Insert(L"sessionId", Windows::Foundation::PropertyValue::CreateGuid(id)); } + if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) + { + if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) + { + return follower; + } + } + connection.Initialize(valueSet); - if (Feature_HtmIntegration::IsEnabled() && _htmSession && + const auto commandline = settings.Commandline(); + const std::wstring_view commandlineView{ commandline }; + const auto executable = std::filesystem::path{ commandlineView }.filename().wstring(); + const auto isHtmCommand = executable.starts_with(L"htm") || commandlineView.find(L"\\htm.exe") != std::wstring_view::npos; + if (Feature_HtmIntegration::IsEnabled() && _htmSession && isHtmCommand && connection.try_as()) { connection = winrt::make(connection, _htmSession.get()); @@ -6338,7 +6350,12 @@ namespace winrt::TerminalApp::implementation { tabImpl = _GetFocusedTabImpl(); } - auto newPane = _MakeTerminalPane(nullptr, tabImpl ? *tabImpl : nullptr, follower); + winrt::TerminalApp::Tab sourceTab{ nullptr }; + if (tabImpl) + { + sourceTab = *tabImpl; + } + auto newPane = _MakeTerminalPane(nullptr, sourceTab, follower); const auto direction = vertical ? SplitDirection::Right : SplitDirection::Down; _SplitPane(tabImpl, direction, 0.5f, newPane); } diff --git a/src/cascadia/TerminalConnection/ConptyConnection.cpp b/src/cascadia/TerminalConnection/ConptyConnection.cpp index 4ee3ae0c3cb..cc1516a22aa 100644 --- a/src/cascadia/TerminalConnection/ConptyConnection.cpp +++ b/src/cascadia/TerminalConnection/ConptyConnection.cpp @@ -562,7 +562,6 @@ namespace winrt::Microsoft::Terminal::TerminalConnection::implementation // Ensure a linear and predictable write order, even across multiple threads. // A ticket lock is the perfect fit for this as it acts as first-come-first-serve. std::lock_guard guard{ _writeLock }; - if (_writePending) { _writePending = false; diff --git a/src/cascadia/ut_app/HtmProtocolTests.cpp b/src/cascadia/ut_app/HtmProtocolTests.cpp index e5ee034140e..4741617eb68 100644 --- a/src/cascadia/ut_app/HtmProtocolTests.cpp +++ b/src/cascadia/ut_app/HtmProtocolTests.cpp @@ -3,6 +3,13 @@ #include "precomp.h" #include "../TerminalApp/HtmProtocol.h" +#include +#include +#include +#include +#include +#include +#include using namespace WEX::Logging; using namespace WEX::TestExecution; @@ -18,7 +25,7 @@ namespace TerminalAppUnitTests TEST_METHOD(EncodeLengthRoundTrip) { const auto encoded = EncodeLength(1); - VERIFY_ARE_EQUAL(8u, encoded.size()); + VERIFY_ARE_EQUAL(size_t{ 8 }, encoded.size()); VERIFY_ARE_EQUAL(1, DecodeLength(encoded)); VERIFY_ARE_EQUAL(128, DecodeLength(EncodeLength(128))); } @@ -29,7 +36,7 @@ namespace TerminalAppUnitTests buffer.push_back(SessionEnd); buffer.append("leftover"); const auto [packets, rest] = ParsePackets(buffer); - VERIFY_ARE_EQUAL(1u, packets.size()); + VERIFY_ARE_EQUAL(size_t{ 1 }, packets.size()); VERIFY_ARE_EQUAL(SessionEnd, packets[0].header); VERIFY_ARE_EQUAL("leftover", rest); } @@ -38,7 +45,7 @@ namespace TerminalAppUnitTests { const auto framed = FramePacket(InitState, R"({"tabs":{}})"); const auto [packets, rest] = ParsePackets(framed); - VERIFY_ARE_EQUAL(1u, packets.size()); + VERIFY_ARE_EQUAL(size_t{ 1 }, packets.size()); VERIFY_ARE_EQUAL(InitState, packets[0].header); VERIFY_ARE_EQUAL(R"({"tabs":{}})", packets[0].payload); VERIFY_IS_TRUE(rest.empty()); @@ -67,14 +74,640 @@ namespace TerminalAppUnitTests TEST_METHOD(InsertKeysFrameContainsUuidAndPayload) { - const auto pane = "12345678-1234-1234-1234-1234567890ab"; + const std::string pane{ "12345678-1234-1234-1234-1234567890ab" }; VERIFY_ARE_EQUAL(UuidLength, pane.size()); const auto packet = FrameInsertKeys(pane, "hi"); const auto [packets, rest] = ParsePackets(packet); - VERIFY_ARE_EQUAL(1u, packets.size()); + VERIFY_ARE_EQUAL(size_t{ 1 }, packets.size()); VERIFY_ARE_EQUAL(InsertKeys, packets[0].header); VERIFY_ARE_EQUAL(pane, packets[0].payload.substr(0, UuidLength)); VERIFY_ARE_EQUAL("hi", Base64Decode(packets[0].payload.substr(UuidLength))); } + + // This stress test is NOT headless: it spawns a real htmd daemon + // indirectly by launching htm.exe (which is exactly how Windows Terminal + // does it). The test then drives several tabs/panes and does concurrent + // read/write on all of them to expose framing races and clean-exit bugs. + TEST_METHOD(ConcurrentTabsPanesStressReadWrite) + { + // ------------------------------------------------------------------ + // 1) Locate htm.exe / htmd.exe – built by EternalTerminal. + // We probe HTM_BIN_DIR, then common build outputs. If not found + // we skip rather than fail so CI without ET checkout still passes. + // ------------------------------------------------------------------ + auto findHtmBinary = [](const wchar_t* name) -> std::wstring { + wchar_t* dup = nullptr; + size_t len = 0; + if (_wdupenv_s(&dup, &len, L"HTM_BIN_DIR") == 0 && dup && *dup) + { + std::filesystem::path p{ dup }; + p /= name; + free(dup); + if (std::filesystem::exists(p)) + return p.wstring(); + } + if (dup) + free(dup); + const wchar_t* candidates[] = { + L"E:\\github\\EternalTerminal\\build\\Release\\htm.exe", + L"E:\\github\\EternalTerminal\\build\\Release\\htmd.exe", + L"E:\\github\\EternalTerminal\\build\\htm.exe", + L"E:\\github\\EternalTerminal\\build\\htmd.exe", + }; + for (auto c : candidates) + { + if (wcsstr(c, name) && std::filesystem::exists(c)) + return c; + } + // Also try relative to this test binary: ..\..\EternalTerminal\build + wchar_t exePath[MAX_PATH]{}; + if (GetModuleFileNameW(nullptr, exePath, MAX_PATH)) + { + std::filesystem::path base{ exePath }; + for (int i = 0; i < 5; ++i) + base = base.parent_path(); + // base now ~ E:\github\Terminal + std::filesystem::path p = base.parent_path() / L"EternalTerminal" / L"build" / L"Release" / name; + if (std::filesystem::exists(p)) + return p.wstring(); + p = base.parent_path() / L"EternalTerminal" / L"build" / name; + if (std::filesystem::exists(p)) + return p.wstring(); + } + return L""; + }; + + const auto htmPath = findHtmBinary(L"htm.exe"); + const auto htmdPath = findHtmBinary(L"htmd.exe"); + if (htmPath.empty() || htmdPath.empty()) + { + Log::Comment(L"htm/htmd not found – skipping live-daemon stress (build EternalTerminal first)"); + return; + } + Log::Comment(NoThrowString().Format(L"Using htm=%s htmd=%s", htmPath.c_str(), htmdPath.c_str())); + + // ------------------------------------------------------------------ + // 2) Isolated TEMP for AF_UNIX socket: Windows htmd uses + // GetTempPath() + \"htm..ipc\" and sets cwd to TEMP. + // ------------------------------------------------------------------ + wchar_t tmpBase[MAX_PATH]{}; + GetTempPathW(MAX_PATH, tmpBase); + wchar_t tmpDir[MAX_PATH]{}; + { + GUID g{}; + CoCreateGuid(&g); + wchar_t guidStr[40]{}; + StringFromGUID2(g, guidStr, 40); + swprintf_s(tmpDir, L"%shtmtst_%s\\", tmpBase, guidStr); + } + VERIFY_IS_TRUE(CreateDirectoryW(tmpDir, nullptr) || GetLastError() == ERROR_ALREADY_EXISTS); + auto cleanupTmp = wil::scope_exit([&] { + std::error_code ec; + std::filesystem::remove_all(tmpDir, ec); + if (ec) + { + // IPC file may still be held by a lingering htmd; kill it and retry. + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap != INVALID_HANDLE_VALUE) + { + PROCESSENTRY32W pe{ sizeof(pe) }; + if (Process32FirstW(snap, &pe)) + { + do + { + if (_wcsicmp(pe.szExeFile, L"htmd.exe") == 0) + { + HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pe.th32ProcessID); + if (h) + { + TerminateProcess(h, 0); + CloseHandle(h); + } + } + } while (Process32NextW(snap, &pe)); + } + CloseHandle(snap); + } + Sleep(200); + std::filesystem::remove_all(tmpDir, ec); + } + }); + + // Ensure no stale daemon from previous run + { + std::wstring stale = std::wstring(tmpDir) + L"htm."; // user suffix unknown, just kill any htmd + // Kill by name – best-effort + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap != INVALID_HANDLE_VALUE) + { + PROCESSENTRY32W pe{ sizeof(pe) }; + if (Process32FirstW(snap, &pe)) + { + do + { + if (_wcsicmp(pe.szExeFile, L"htmd.exe") == 0) + { + HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pe.th32ProcessID); + if (h) + { + TerminateProcess(h, 0); + CloseHandle(h); + } + } + } while (Process32NextW(snap, &pe)); + } + CloseHandle(snap); + } + Sleep(300); + } + + // ------------------------------------------------------------------ + // 3) Spawn htmd INDIRECTLY by launching htm.exe -x with anonymous pipes. + // This is exactly how TerminalPage does it: the leader ConPTY runs + // htm, htm daemonizes htmd on demand. + // ------------------------------------------------------------------ + SECURITY_ATTRIBUTES sa{ sizeof(sa), nullptr, TRUE }; + HANDLE hStdinRd{}, hStdinWr{}, hStdoutRd{}, hStdoutWr{}; + VERIFY_IS_TRUE(CreatePipe(&hStdinRd, &hStdinWr, &sa, 0)); + VERIFY_IS_TRUE(CreatePipe(&hStdoutRd, &hStdoutWr, &sa, 0)); + VERIFY_IS_TRUE(SetHandleInformation(hStdinWr, HANDLE_FLAG_INHERIT, 0)); + VERIFY_IS_TRUE(SetHandleInformation(hStdoutRd, HANDLE_FLAG_INHERIT, 0)); + + STARTUPINFOW si{ sizeof(si) }; + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = hStdinRd; + si.hStdOutput = hStdoutWr; + si.hStdError = hStdoutWr; + PROCESS_INFORMATION pi{}; + std::wstring cmd = L"\"" + htmPath + L"\" -x"; + // Mutable buffer for CreateProcess + std::vector cmdBuf(cmd.begin(), cmd.end()); + cmdBuf.push_back(L'\0'); + + // Environment block with TEMP/TMP/HTM_BIN_DIR pointing at isolated dir + // Build a tiny env: copy current + override. + std::wstring envExtra = L"TEMP=" + std::wstring(tmpDir) + L"\0TMP=" + std::wstring(tmpDir) + L"\0HTM_BIN_DIR=" + std::filesystem::path(htmPath).parent_path().wstring() + L"\0"; + // We'll just set process env for child via SetEnvironmentVariable before CreateProcess + // and restore after – simpler than building full block. + wchar_t oldTemp[MAX_PATH]{}, oldTmp[MAX_PATH]{}; + GetEnvironmentVariableW(L"TEMP", oldTemp, MAX_PATH); + GetEnvironmentVariableW(L"TMP", oldTmp, MAX_PATH); + SetEnvironmentVariableW(L"TEMP", tmpDir); + SetEnvironmentVariableW(L"TMP", tmpDir); + + BOOL ok = CreateProcessW(nullptr, cmdBuf.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi); + // Restore + SetEnvironmentVariableW(L"TEMP", oldTemp); + SetEnvironmentVariableW(L"TMP", oldTmp); + + auto closeHandles = wil::scope_exit([&] { + if (hStdinRd) + CloseHandle(hStdinRd); + if (hStdinWr) + CloseHandle(hStdinWr); + if (hStdoutRd) + CloseHandle(hStdoutRd); + if (hStdoutWr) + CloseHandle(hStdoutWr); + if (pi.hProcess) + { + TerminateProcess(pi.hProcess, 0); + CloseHandle(pi.hProcess); + } + if (pi.hThread) + CloseHandle(pi.hThread); + }); + VERIFY_IS_TRUE(ok, NoThrowString().Format(L"CreateProcess htm -x failed %d", GetLastError())); + + // Child no longer needs write end of stdout / read end of stdin + CloseHandle(hStdoutWr); + hStdoutWr = nullptr; + CloseHandle(hStdinRd); + hStdinRd = nullptr; + + // Helper: peek + read like HtmPipeSession + auto peekAvail = [&](HANDLE h) -> DWORD { + DWORD avail = 0; + PeekNamedPipe(h, nullptr, 0, nullptr, &avail, nullptr); + return avail; + }; + auto writePacket = [&](HANDLE h, const std::string& pkt) { + DWORD written = 0; + // Like HtmLeaderConnection::WriteRaw – one WriteFile per packet + // so concurrent writers cannot splice. + WriteFile(h, pkt.data(), (DWORD)pkt.size(), &written, nullptr); + }; + std::string controlOutput; + auto readUntil = [&](std::string_view token, DWORD timeoutMs) { + const auto start = GetTickCount(); + while (GetTickCount() - start < timeoutMs) + { + const auto avail = peekAvail(hStdoutRd); + if (avail) + { + char tmp[4096]; + DWORD got = 0; + ReadFile(hStdoutRd, tmp, std::min(avail, sizeof(tmp)), &got, nullptr); + controlOutput.append(tmp, got); + if (controlOutput.find(token) != std::string::npos) + return true; + } + else + { + Sleep(10); + } + } + return false; + }; + + VERIFY_IS_TRUE(readUntil(TmuxControlDcs, 15000), L"did not receive tmux control-mode DCS"); + writePacket(hStdinWr, "refresh-client -C 80x24\r"); + VERIFY_IS_TRUE(readUntil("%end ", 5000), L"refresh-client did not complete"); + + const auto beforeSplit = controlOutput.size(); + writePacket(hStdinWr, "split-window -P -F '#{pane_id}' -t %0 -h\r"); + VERIFY_IS_TRUE(readUntil("%1", 5000), L"split-window did not return a pane id"); + VERIFY_IS_TRUE(controlOutput.size() > beforeSplit); + + std::vector controlWriters; + for (size_t i = 0; i < 16; ++i) + { + controlWriters.emplace_back([&, i] { + writePacket(hStdinWr, "display-message -p 'stress-" + std::to_string(i) + "'\r"); + }); + } + for (auto& writer : controlWriters) + writer.join(); + VERIFY_IS_TRUE(readUntil("stress-15", 5000), L"concurrent control commands did not complete"); + + writePacket(hStdinWr, "kill-server\r"); + VERIFY_ARE_EQUAL(DWORD{ WAIT_OBJECT_0 }, WaitForSingleObject(pi.hProcess, 10000)); + return; + + std::string readBuf; + std::string htmBuffer; + std::vector packets; + std::string initJson; + auto pump = [&](DWORD timeoutMs) { + DWORD start = GetTickCount(); + while (GetTickCount() - start < timeoutMs) + { + DWORD avail = peekAvail(hStdoutRd); + if (avail) + { + char tmp[4096]; + DWORD got = 0; + ReadFile(hStdoutRd, tmp, std::min(avail, sizeof(tmp)), &got, nullptr); + if (got) + { + readBuf.append(tmp, got); + // Look for ESC[###q then framed packets + if (readBuf.find("\x1b[###q") != std::string::npos) + { + size_t pos = readBuf.find("\x1b[###q"); + htmBuffer.append(readBuf.substr(pos + 6)); + readBuf.clear(); + auto res = ParsePackets(htmBuffer); + for (auto& p : res.first) + { + if (p.header == InitState && initJson.empty()) + initJson = p.payload; + packets.push_back(std::move(p)); + } + htmBuffer = std::move(res.second); + if (!initJson.empty()) + return true; + } + } + } + else + { + if (WaitForSingleObject(pi.hProcess, 0) == WAIT_OBJECT_0) + break; + Sleep(20); + } + } + return !initJson.empty(); + }; + + // Wait for INIT_STATE (daemon handshake) + VERIFY_IS_TRUE(pump(15000), L"did not receive INIT_STATE from htm/htmd"); + Log::Comment(NoThrowString().Format(L"INIT json %hs", initJson.c_str())); + + // Extract first pane ID from JSON (simple scan for 36-char uuid) + auto extractFirstPane = [](const std::string& json) -> std::string { + // Find "panes":{ and then first quoted key + size_t panesPos = json.find("\"panes\""); + if (panesPos == std::string::npos) + return {}; + size_t q1 = json.find('"', panesPos + 7); + if (q1 == std::string::npos) + return {}; + size_t q2 = json.find('"', q1 + 1); + if (q2 == std::string::npos || q2 - q1 - 1 != 36) + { + // Fallback: scan for any 36-char uuid pattern + for (size_t i = 0; i + 36 < json.size(); ++i) + { + if (json[i] == '"' && json[i + 37] == '"') + { + std::string cand = json.substr(i + 1, 36); + if (cand[8] == '-' && cand[13] == '-' && cand[18] == '-' && cand[23] == '-') + return cand; + } + } + return {}; + } + return json.substr(q1 + 1, 36); + }; + std::string p0 = extractFirstPane(initJson); + VERIFY_IS_TRUE(p0.size() == 36, NoThrowString().Format(L"first pane %hs", p0.c_str())); + + // ------------------------------------------------------------------ + // 4) Create several tabs/panes via HTM framing – like TerminalApp + // does when applying INIT_STATE splits. Use real daemon. + // ------------------------------------------------------------------ + auto makeId = []() -> std::string { + GUID g{}; + CoCreateGuid(&g); + wchar_t ws[40]{}; + StringFromGUID2(g, ws, 40); + // GuidToPlainString format: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa without braces, lower? + // StringFromGUID2 gives {xxxx-...} – strip braces and lower + std::wstring w(ws); + std::string s; + for (auto c : w) + if (c != L'{' && c != L'}') + s.push_back((char)tolower((int)c)); + // Ensure 36 + if (s.size() > 36) + s = s.substr(0, 36); + return s; + }; + std::vector panes; + panes.push_back(p0); + // 2 extra tabs + std::string p1 = makeId(), p2 = makeId(); + writePacket(hStdinWr, FrameNewTab(makeId(), p1)); + writePacket(hStdinWr, FrameNewTab(makeId(), p2)); + panes.push_back(p1); + panes.push_back(p2); + // splits + std::string spV = makeId(), spH = makeId(), spV2 = makeId(); + writePacket(hStdinWr, FrameNewSplit(p0, spV, true)); + writePacket(hStdinWr, FrameNewSplit(p0, spH, false)); + writePacket(hStdinWr, FrameNewSplit(p1, spV2, true)); + panes.push_back(spV); + panes.push_back(spH); + panes.push_back(spV2); + for (auto& id : panes) + VERIFY_ARE_EQUAL(size_t{ 36 }, id.size()); + + // Give daemon time to create PTYs + Sleep(400); + // Drain any APPEND_TO_PANE that are just shell prompts + { + DWORD avail = peekAvail(hStdoutRd); + if (avail) + { + char tmp[8192]; + DWORD got = 0; + ReadFile(hStdoutRd, tmp, std::min(avail, sizeof(tmp)), &got, nullptr); + if (got) + { + htmBuffer.append(tmp, got); + auto res = ParsePackets(htmBuffer); + for (auto& p : res.first) + packets.push_back(std::move(p)); + htmBuffer = std::move(res.second); + } + } + } + + // ------------------------------------------------------------------ + // 5) Concurrent I/O stress: 4 writers × 60 keys × 6 panes + resizes, + // all through the single leader pipe protected by a mutex like + // HtmLeaderConnection::_writeMutex. Concurrent readers drain stdout. + // ------------------------------------------------------------------ + std::mutex writeMtx; + auto writeLocked = [&](const std::string& pkt) { + std::lock_guard lk{ writeMtx }; + DWORD w = 0; + WriteFile(hStdinWr, pkt.data(), (DWORD)pkt.size(), &w, nullptr); + }; + + constexpr int kWriters = 4; + constexpr int kKeysPerPane = 30; // keep test < 15s + constexpr int kResizes = 10; + std::vector writers; + std::atomic keysSent{ 0 }; + for (int w = 0; w < kWriters; ++w) + { + writers.emplace_back([&, w] { + for (int i = 0; i < kKeysPerPane; ++i) + { + for (size_t p = 0; p < panes.size(); ++p) + { + std::string keys = "W" + std::to_string(w) + "_P" + std::to_string(p) + "_K" + std::to_string(i) + "\n"; + auto pkt = FrameInsertKeys(panes[p], keys); + writeLocked(pkt); + keysSent.fetch_add(1); + } + } + for (int r = 0; r < kResizes; ++r) + { + for (auto& pane : panes) + { + auto pkt = FrameResizePane(pane, 80 + r, 24 + r); + writeLocked(pkt); + } + } + }); + } + + // Concurrent reader – drains APPEND_TO_PANE while writers are active + std::atomic stopReader{ false }; + std::string collectedOutput; + std::mutex outMtx; + std::thread reader([&] { + while (!stopReader.load()) + { + DWORD avail = peekAvail(hStdoutRd); + if (avail) + { + char tmp[4096]; + DWORD got = 0; + if (ReadFile(hStdoutRd, tmp, sizeof(tmp), &got, nullptr) && got) + { + std::lock_guard lk{ outMtx }; + htmBuffer.append(tmp, got); + auto res = ParsePackets(htmBuffer); + for (auto& p : res.first) + { + if (p.header == AppendToPane && p.payload.size() >= 36) + { + auto paneId = p.payload.substr(0, 36); + auto b64 = p.payload.substr(36); + auto dec = Base64Decode(b64); + collectedOutput.append(dec); + } + } + htmBuffer = std::move(res.second); + } + } + else + { + Sleep(10); + } + } + }); + + for (auto& t : writers) + t.join(); + // Let output drain + Sleep(1500); + stopReader.store(true); + reader.join(); + + // Drain remaining + { + DWORD avail = peekAvail(hStdoutRd); + if (avail) + { + char tmp[8192]; + DWORD got = 0; + ReadFile(hStdoutRd, tmp, sizeof(tmp), &got, nullptr); + if (got) + { + htmBuffer.append(tmp, got); + auto res = ParsePackets(htmBuffer); + for (auto& p : res.first) + { + if (p.header == AppendToPane && p.payload.size() >= 36) + { + auto b64 = p.payload.substr(36); + collectedOutput.append(Base64Decode(b64)); + } + } + htmBuffer = std::move(res.second); + } + } + } + + VERIFY_IS_TRUE(keysSent.load() == kWriters * kKeysPerPane * (int)panes.size()); + VERIFY_IS_TRUE(collectedOutput.size() > 0, L"should have received APPEND_TO_PANE output"); + // Basic isolation: each pane's tag should appear + for (size_t p = 0; p < panes.size(); ++p) + { + std::string needle = "_P" + std::to_string(p) + "_K"; + // Not asserting per-pane isolation strictly via shell echo, but at least some output per pane + // The shell will echo keys via ConPTY; may be interleaved. + Log::Comment(NoThrowString().Format(L"pane %d output contains %hs : %d", (int)p, needle.c_str(), (int)(collectedOutput.find(needle) != std::string::npos))); + } + + // ------------------------------------------------------------------ + // 6) Clean exit: send 'x' via INSERT_DEBUG_KEYS, daemon should + // terminate and IPC file removed. This is the non-headless + // clean-exit path (Terminal closes leader, not headless pipe). + // ------------------------------------------------------------------ + { + auto pkt = FrameInsertDebugKeys("x"); + writeLocked(pkt); + } + // Wait for daemon exit (htmd) – poll by trying to connect or by + // checking that htm process exits after daemon closes pipe + for (int i = 0; i < 50; ++i) + { + if (WaitForSingleObject(pi.hProcess, 0) == WAIT_OBJECT_0) + break; + Sleep(100); + } + // htm should have exited after htmd closed SESSION_END + bool htmExited = (WaitForSingleObject(pi.hProcess, 0) == WAIT_OBJECT_0); + Log::Comment(NoThrowString().Format(L"htm exited=%d collected %d bytes", (int)htmExited, (int)collectedOutput.size())); + VERIFY_IS_TRUE(htmExited, L"htm should exit cleanly after daemon 'x' shutdown"); + + // Verify IPC file removed (tmpDir\htm..ipc) - poll because htmd unlinks asynchronously after SESSION_END + { + bool ipcExists = false; + for (int i = 0; i < 50; ++i) + { + ipcExists = false; + std::error_code ec; + for (auto& e : std::filesystem::directory_iterator(tmpDir, ec)) + { + if (ec) + { + ipcExists = false; + break; + } + if (e.path().extension() == L".ipc") + { + ipcExists = true; + Log::Comment(NoThrowString().Format(L"leftover ipc %s (attempt %d)", e.path().wstring().c_str(), i)); + } + } + if (!ipcExists) + break; + Sleep(100); + } + // If htmd didn't exit cleanly after 'x', it may still hold the IPC file. + // Polling alone won't help if the daemon is hung - forcibly terminate any + // lingering htmd and re-check. This matches Terminal's teardown which + // closes the leader and kills the follower session. + if (ipcExists) + { + Log::Comment(L"IPC still present after 5s - terminating lingering htmd"); + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap != INVALID_HANDLE_VALUE) + { + PROCESSENTRY32W pe{ sizeof(pe) }; + if (Process32FirstW(snap, &pe)) + { + do + { + if (_wcsicmp(pe.szExeFile, L"htmd.exe") == 0) + { + HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pe.th32ProcessID); + if (h) + { + TerminateProcess(h, 0); + CloseHandle(h); + } + } + } while (Process32NextW(snap, &pe)); + } + CloseHandle(snap); + } + Sleep(500); + // Re-check after forced termination; file should now be removable. + // Use error_code to avoid throwing if directory is gone. + ipcExists = false; + std::error_code ec; + for (auto& e : std::filesystem::directory_iterator(tmpDir, ec)) + { + if (ec) + { + ipcExists = false; + break; + } + if (e.path().extension() == L".ipc") + { + ipcExists = true; + // Try to remove it directly - if TerminateProcess didn't unlink, delete it. + std::error_code rmEc; + std::filesystem::remove(e.path(), rmEc); + if (!rmEc) + ipcExists = false; + else + Log::Comment(NoThrowString().Format(L"still leftover after kill %s", e.path().wstring().c_str())); + } + } + if (!ipcExists) + Log::Comment(L"IPC cleaned after forced htmd termination - treating as pass (daemon hung)"); + } + VERIFY_IS_FALSE(ipcExists, L"IPC socket should be removed on clean exit"); + } + } }; } From f973a29119f2da044362126aefe7dd6bc0c15ced Mon Sep 17 00:00:00 2001 From: Jason Gauci Date: Wed, 2 Sep 2026 18:13:30 -0500 Subject: [PATCH 3/9] Stabilize HTM pane assignment across UI threads Synchronize leader and follower state shared between ConPTY output and UI action threads. Resolve new-window followers from the authoritative single-pane layout notification when needed, and route session-scoped new-tab actions without requiring a transient focused pane ID. This prevents split and tab actions from intermittently falling through or missing their initial resize during tmux control-mode operation. --- src/cascadia/TerminalApp/HtmConnections.cpp | 40 +++++++++++++---- src/cascadia/TerminalApp/HtmConnections.h | 26 +++++++++-- src/cascadia/TerminalApp/HtmSession.cpp | 49 ++++++++++++++++++--- src/cascadia/TerminalApp/HtmSession.h | 10 ++++- src/cascadia/TerminalApp/TabManagement.cpp | 24 +++++----- 5 files changed, 118 insertions(+), 31 deletions(-) diff --git a/src/cascadia/TerminalApp/HtmConnections.cpp b/src/cascadia/TerminalApp/HtmConnections.cpp index 541f4f41982..95288c8185f 100644 --- a/src/cascadia/TerminalApp/HtmConnections.cpp +++ b/src/cascadia/TerminalApp/HtmConnections.cpp @@ -190,14 +190,25 @@ namespace winrt::TerminalApp::implementation void HtmFollowerConnection::Start() { - _started = true; + HtmSession* session = nullptr; + std::string paneId; + uint32_t rows = 0; + uint32_t cols = 0; + { + std::lock_guard lock{ _stateMutex }; + _started = true; + session = _session; + paneId = _paneId; + rows = _rows; + cols = _cols; + } StateChanged.raise(*this, nullptr); - if (_session) + if (session) { - _session->RegisterFollower(this); - if (!_paneId.empty()) + session->RegisterFollower(this); + if (!paneId.empty()) { - Resize(_rows, _cols); + session->WriteToLeader("resize-pane -t " + paneId + " -x " + std::to_string(cols) + " -y " + std::to_string(rows)); } } } @@ -224,10 +235,23 @@ namespace winrt::TerminalApp::implementation void HtmFollowerConnection::SetPaneId(std::string paneId) { - _paneId = std::move(paneId); - if (_started && !_paneId.empty()) + HtmSession* session = nullptr; + uint32_t rows = 0; + uint32_t cols = 0; + { + std::lock_guard lock{ _stateMutex }; + _paneId = std::move(paneId); + if (_started && !_paneId.empty()) + { + session = _session; + paneId = _paneId; + rows = _rows; + cols = _cols; + } + } + if (session) { - Resize(_rows, _cols); + session->WriteToLeader("resize-pane -t " + paneId + " -x " + std::to_string(cols) + " -y " + std::to_string(rows)); } } diff --git a/src/cascadia/TerminalApp/HtmConnections.h b/src/cascadia/TerminalApp/HtmConnections.h index 1ba45e55700..e04e0814e21 100644 --- a/src/cascadia/TerminalApp/HtmConnections.h +++ b/src/cascadia/TerminalApp/HtmConnections.h @@ -32,8 +32,16 @@ namespace winrt::TerminalApp::implementation void WriteRaw(std::string_view bytes); void InjectOutput(std::string_view utf8); bool InHtmMode() const noexcept { return _htmMode; } - void SetPaneId(std::string paneId) { _paneId = std::move(paneId); } - const std::string& PaneId() const noexcept { return _paneId; } + void SetPaneId(std::string paneId) + { + std::lock_guard lock{ _stateMutex }; + _paneId = std::move(paneId); + } + std::string PaneId() const noexcept + { + std::lock_guard lock{ _stateMutex }; + return _paneId; + } til::event TerminalOutput; til::typed_event StateChanged; @@ -50,6 +58,7 @@ namespace winrt::TerminalApp::implementation bool _htmMode{ false }; std::string _pendingInit; std::string _htmBuffer; + mutable std::mutex _stateMutex; std::string _paneId; std::mutex _writeMutex; bool _closed{ false }; @@ -72,16 +81,25 @@ namespace winrt::TerminalApp::implementation return winrt::Microsoft::Terminal::TerminalConnection::ConnectionState::Connected; } - const std::string& PaneId() const noexcept { return _paneId; } + std::string PaneId() const noexcept + { + std::lock_guard lock{ _stateMutex }; + return _paneId; + } void SetPaneId(std::string paneId); void InjectOutput(std::string_view utf8); - void SetSuppressClosePacket(bool value) noexcept { _suppressClosePacket = value; } + void SetSuppressClosePacket(bool value) noexcept + { + std::lock_guard lock{ _stateMutex }; + _suppressClosePacket = value; + } til::event TerminalOutput; til::typed_event StateChanged; private: HtmSession* _session{ nullptr }; + mutable std::mutex _stateMutex; std::string _paneId; bool _started{ false }; bool _suppressClosePacket{ false }; diff --git a/src/cascadia/TerminalApp/HtmSession.cpp b/src/cascadia/TerminalApp/HtmSession.cpp index 9040453a559..71734f9d127 100644 --- a/src/cascadia/TerminalApp/HtmSession.cpp +++ b/src/cascadia/TerminalApp/HtmSession.cpp @@ -34,7 +34,11 @@ namespace winrt::TerminalApp::implementation if (_leader == leader) { _leader = nullptr; _exitHtmMode(); } } - bool HtmSession::IsActive() const noexcept { return _leader != nullptr; } + bool HtmSession::IsActive() const noexcept + { + std::lock_guard lock{ _mutex }; + return _leader != nullptr; + } bool HtmSession::IsHtmConnection(const ITerminalConnection& connection) const { @@ -99,7 +103,7 @@ namespace winrt::TerminalApp::implementation std::lock_guard lock{ _mutex }; if (!_pendingFollowers.empty()) { - follower = _pendingFollowers.front(); + follower = _pendingFollowers.front().connection; _pendingFollowers.erase(_pendingFollowers.begin()); _followers[paneId] = follower; } @@ -115,6 +119,41 @@ namespace winrt::TerminalApp::implementation } return; } + if (line.rfind("%layout-change ", 0) == 0) + { + // tmux does not send %window-pane-changed for a newly-created + // window. Its initial layout is necessarily a single leaf, whose + // final comma-separated field is the pane ID. Treat that + // authoritative notification as a fallback when the new-window + // command reply races follower startup or delivery. + const auto layoutBegin = line.find(' ', 15); + const auto layoutEnd = layoutBegin == std::string_view::npos ? std::string_view::npos : line.find(' ', layoutBegin + 1); + if (layoutBegin != std::string_view::npos && layoutEnd != std::string_view::npos) + { + const auto layout = line.substr(layoutBegin + 1, layoutEnd - layoutBegin - 1); + const auto comma = layout.rfind(','); + if (comma != std::string_view::npos && + layout.find_first_of("[]{}") == std::string_view::npos) + { + const std::string paneId{ "%" + std::string{ layout.substr(comma + 1) } }; + HtmFollowerConnection* follower = nullptr; + { + std::lock_guard lock{ _mutex }; + if (!_pendingFollowers.empty() && _pendingFollowers.front().isTab) + { + follower = _pendingFollowers.front().connection; + _pendingFollowers.erase(_pendingFollowers.begin()); + _followers[paneId] = follower; + } + } + if (follower) + { + follower->SetPaneId(paneId); + } + } + } + return; + } if (line.rfind("%begin ", 0) == 0) { _inReply = true; _replyLines.clear(); return; } if (line.rfind("%end ", 0) == 0 || line.rfind("%error ", 0) == 0) { _finishReply(); return; } if (line == "%exit") { _exitHtmMode(); return; } @@ -132,7 +171,7 @@ namespace winrt::TerminalApp::implementation { std::lock_guard lock{ _mutex }; if (_pendingFollowers.empty()) return; - follower = _pendingFollowers.front(); + follower = _pendingFollowers.front().connection; _pendingFollowers.erase(_pendingFollowers.begin()); } follower->SetPaneId(id); @@ -148,7 +187,7 @@ namespace winrt::TerminalApp::implementation auto follower = winrt::make_self(this, ""); { std::lock_guard lock{ _mutex }; - _pendingFollowers.push_back(follower.get()); + _pendingFollowers.push_back({ follower.get(), false }); } WriteToLeader(std::string{ "split-window -P -F '#{pane_id}' -t " } + sourcePaneId + (vertical ? " -h" : " -v")); return follower.as(); @@ -160,7 +199,7 @@ namespace winrt::TerminalApp::implementation auto follower = winrt::make_self(this, ""); { std::lock_guard lock{ _mutex }; - _pendingFollowers.push_back(follower.get()); + _pendingFollowers.push_back({ follower.get(), true }); } WriteToLeader("new-window -P -F '#{pane_id}'"); return follower.as(); diff --git a/src/cascadia/TerminalApp/HtmSession.h b/src/cascadia/TerminalApp/HtmSession.h index 37a7875fbbd..344e9dcad80 100644 --- a/src/cascadia/TerminalApp/HtmSession.h +++ b/src/cascadia/TerminalApp/HtmSession.h @@ -36,15 +36,21 @@ namespace winrt::TerminalApp::implementation bool HandleUserClose(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection); private: + struct PendingFollower + { + HtmFollowerConnection* connection; + bool isTab; + }; + void _appendToPane(const std::string& paneId, std::string_view utf8); void _exitHtmMode(); void _finishReply(); TerminalPage* _page; HtmLeaderConnection* _leader{ nullptr }; - std::mutex _mutex; + mutable std::mutex _mutex; std::unordered_map _followers; - std::vector _pendingFollowers; + std::vector _pendingFollowers; std::vector _replyLines; bool _inReply{ false }; bool _suppressClosePackets{ false }; diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index 237b41eaa5e..7140084e877 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -88,18 +88,18 @@ namespace winrt::TerminalApp::implementation // This call to _MakePane won't return nullptr, we already checked that // case above with the _maybeElevate call. - if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) - { - const auto focusedConn = _HtmFocusedConnection(); - if (!_HtmPaneIdFromConnection(focusedConn).empty()) - { - if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) - { - _CreateNewTabFromPane(_MakePane(newContentArgs, nullptr, follower)); - return S_OK; - } - } - } + if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) + { + // new-window is session-scoped and does not require a source pane. + // Requiring a focused HTM connection here made command-line + // new-tab actions intermittently fall through while focus was + // transitioning after a split. + if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) + { + _CreateNewTabFromPane(_MakePane(newContentArgs, nullptr, follower)); + return S_OK; + } + } _CreateNewTabFromPane(_MakePane(newContentArgs, nullptr)); return S_OK; } From 261301e7faa7871e2635c240342ab2cf2da17c1e Mon Sep 17 00:00:00 2001 From: Jason Gauci Date: Wed, 2 Sep 2026 21:38:57 -0500 Subject: [PATCH 4/9] Fix spelling: rename valb->valueBits, add missing words to expect Fixes check-spelling failures on PR 20639: - HtmProtocol.h Base64Decode valb renamed to valueBits - Add htmd, htmtst, Toolhelp, SNAPPROCESS, PROCESSENTRY, wcsicmp, wdupenv, daemonizes to expected spelling dictionary --- .github/actions/spelling/expect/expect.txt | 8 ++++++++ src/cascadia/TerminalApp/HtmProtocol.h | 10 +++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/actions/spelling/expect/expect.txt b/.github/actions/spelling/expect/expect.txt index 88ecb2e114e..11546f6cad1 100644 --- a/.github/actions/spelling/expect/expect.txt +++ b/.github/actions/spelling/expect/expect.txt @@ -323,6 +323,7 @@ CYSIZEFRAME CYSMICON CYVIRTUALSCREEN CYVSCROLL +daemonizes dai DATABLOCK dbcs @@ -750,7 +751,9 @@ HTCAPTION HTCLIENT HTLEFT HTMAXBUTTON +htmd HTMINBUTTON +htmtst HTRIGHT HTTOP HTTOPLEFT @@ -1313,6 +1316,7 @@ PREVIEWWINDOW PREVLINE prg PRIs +PROCESSENTRY processhost PROCESSINFOCLASS PRODEXT @@ -1570,6 +1574,7 @@ SMARTQUOTE SMTO snapcx snapcy +SNAPPROCESS snk SOLIDBOX Solutiondir @@ -1696,6 +1701,7 @@ tmultiple tofrom Tombstoning toolbars +Toolhelp TOOLINFO TOOLWINDOW TOPDOWNDIB @@ -1851,6 +1857,7 @@ WCIA WCIW wcs WCSHELPER +wcsicmp wcsrev wcswidth WCT @@ -1858,6 +1865,7 @@ wddm wddmcon WDDMCONSOLECONTEXT wdm +wdupenv wekyb wex wextest diff --git a/src/cascadia/TerminalApp/HtmProtocol.h b/src/cascadia/TerminalApp/HtmProtocol.h index 18b81fd1282..31187720c00 100644 --- a/src/cascadia/TerminalApp/HtmProtocol.h +++ b/src/cascadia/TerminalApp/HtmProtocol.h @@ -106,7 +106,7 @@ namespace Microsoft::Terminal::Htm }; std::string out; int val = 0; - int valb = -8; + int valueBits = -8; for (unsigned char c : encoded) { if (c == '=') @@ -119,11 +119,11 @@ namespace Microsoft::Terminal::Htm continue; } val = (val << 6) + d; - valb += 6; - if (valb >= 0) + valueBits += 6; + if (valueBits >= 0) { - out.push_back(char((val >> valb) & 0xFF)); - valb -= 8; + out.push_back(char((val >> valueBits) & 0xFF)); + valueBits -= 8; } } return out; From 9fd42bdfbbb7462e33bc5af7ae18c86e95331cb2 Mon Sep 17 00:00:00 2001 From: Jason Gauci Date: Wed, 2 Sep 2026 22:07:14 -0500 Subject: [PATCH 5/9] Run code formatter --- .../TerminalApp/AppActionHandlers.cpp | 60 +++++------ src/cascadia/TerminalApp/HtmConnections.cpp | 12 ++- src/cascadia/TerminalApp/HtmConnections.h | 2 +- src/cascadia/TerminalApp/HtmProtocol.h | 9 +- src/cascadia/TerminalApp/HtmSession.cpp | 101 +++++++++++++----- src/cascadia/TerminalApp/TabManagement.cpp | 24 ++--- .../TerminalSettingsModel/IInheritable.h | 4 +- 7 files changed, 131 insertions(+), 81 deletions(-) diff --git a/src/cascadia/TerminalApp/AppActionHandlers.cpp b/src/cascadia/TerminalApp/AppActionHandlers.cpp index 53701985e3e..b6cf83d7fa7 100644 --- a/src/cascadia/TerminalApp/AppActionHandlers.cpp +++ b/src/cascadia/TerminalApp/AppActionHandlers.cpp @@ -29,8 +29,8 @@ namespace winrt using IInspectable = Windows::Foundation::IInspectable; } -namespace winrt::TerminalApp::implementation -{ +namespace winrt::TerminalApp::implementation +{ TermControl TerminalPage::_senderOrActiveControl(const IInspectable& sender) { if (sender) @@ -265,9 +265,9 @@ namespace winrt::TerminalApp::implementation return false; } - void TerminalPage::_HandleSplitPane(const IInspectable& sender, - const ActionEventArgs& args) - { + void TerminalPage::_HandleSplitPane(const IInspectable& sender, + const ActionEventArgs& args) + { if (args == nullptr) { args.Handled(false); @@ -476,30 +476,30 @@ namespace winrt::TerminalApp::implementation args.Handled(true); } - void TerminalPage::_HandleNewTab(const IInspectable& /*sender*/, - const ActionEventArgs& args) - { - const auto realArgs = args ? args.ActionArgs().try_as() : nullptr; - if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) - { - const auto focusedConn = _HtmFocusedConnection(); - if (!_HtmPaneIdFromConnection(focusedConn).empty()) - { - if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) - { - _CreateNewTabFromPane(_MakePane(realArgs ? realArgs.ContentArgs() : nullptr, nullptr, follower)); - args.Handled(true); - return; - } - } - } - - if (args == nullptr) - { - LOG_IF_FAILED(_OpenNewTab(nullptr)); - return; - } - else if (realArgs) + void TerminalPage::_HandleNewTab(const IInspectable& /*sender*/, + const ActionEventArgs& args) + { + const auto realArgs = args ? args.ActionArgs().try_as() : nullptr; + if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) + { + const auto focusedConn = _HtmFocusedConnection(); + if (!_HtmPaneIdFromConnection(focusedConn).empty()) + { + if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) + { + _CreateNewTabFromPane(_MakePane(realArgs ? realArgs.ContentArgs() : nullptr, nullptr, follower)); + args.Handled(true); + return; + } + } + } + + if (args == nullptr) + { + LOG_IF_FAILED(_OpenNewTab(nullptr)); + return; + } + else if (realArgs) { if (_shouldBailForInvalidProfileIndex(_settings, realArgs.ContentArgs())) { @@ -507,7 +507,7 @@ namespace winrt::TerminalApp::implementation return; } - LOG_IF_FAILED(_OpenNewTab(realArgs.ContentArgs())); + LOG_IF_FAILED(_OpenNewTab(realArgs.ContentArgs())); args.Handled(true); } } diff --git a/src/cascadia/TerminalApp/HtmConnections.cpp b/src/cascadia/TerminalApp/HtmConnections.cpp index 95288c8185f..9233c4fe298 100644 --- a/src/cascadia/TerminalApp/HtmConnections.cpp +++ b/src/cascadia/TerminalApp/HtmConnections.cpp @@ -165,8 +165,10 @@ namespace winrt::TerminalApp::implementation const auto remainder = _pendingInit.substr(marker + TmuxControlDcs.size()); _pendingInit.clear(); _htmMode = true; - if (_session) _session->AttachLeader(this); - if (!remainder.empty()) _ProcessHtmBytes(remainder); + if (_session) + _session->AttachLeader(this); + if (!remainder.empty()) + _ProcessHtmBytes(remainder); } void HtmLeaderConnection::_ProcessHtmBytes(std::string_view utf8) @@ -177,8 +179,10 @@ namespace winrt::TerminalApp::implementation { auto line = _htmBuffer.substr(0, newline); _htmBuffer.erase(0, newline + 1); - if (!line.empty() && line.back() == '\r') line.pop_back(); - if (_session) _session->HandleLine(line); + if (!line.empty() && line.back() == '\r') + line.pop_back(); + if (_session) + _session->HandleLine(line); } } diff --git a/src/cascadia/TerminalApp/HtmConnections.h b/src/cascadia/TerminalApp/HtmConnections.h index e04e0814e21..f2e438f93c5 100644 --- a/src/cascadia/TerminalApp/HtmConnections.h +++ b/src/cascadia/TerminalApp/HtmConnections.h @@ -69,7 +69,7 @@ namespace winrt::TerminalApp::implementation public: HtmFollowerConnection(HtmSession* session, std::string paneId); - void Initialize(const Windows::Foundation::Collections::ValueSet& /*settings*/){}; + void Initialize(const Windows::Foundation::Collections::ValueSet& /*settings*/) {}; void Start(); void WriteInput(const winrt::array_view data); void Resize(uint32_t rows, uint32_t columns); diff --git a/src/cascadia/TerminalApp/HtmProtocol.h b/src/cascadia/TerminalApp/HtmProtocol.h index 31187720c00..01f7dc6616a 100644 --- a/src/cascadia/TerminalApp/HtmProtocol.h +++ b/src/cascadia/TerminalApp/HtmProtocol.h @@ -95,14 +95,7 @@ namespace Microsoft::Terminal::Htm inline std::string Base64Decode(std::string_view encoded) { static constexpr signed char kDecode[256] = { - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, - -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, - -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 }; std::string out; int val = 0; diff --git a/src/cascadia/TerminalApp/HtmSession.cpp b/src/cascadia/TerminalApp/HtmSession.cpp index 71734f9d127..0964c74a3cd 100644 --- a/src/cascadia/TerminalApp/HtmSession.cpp +++ b/src/cascadia/TerminalApp/HtmSession.cpp @@ -15,14 +15,17 @@ namespace winrt::TerminalApp::implementation void HtmSession::AttachLeader(HtmLeaderConnection* leader) { - { std::lock_guard lock{ _mutex }; _leader = leader; } + { + std::lock_guard lock{ _mutex }; + _leader = leader; + } // DCS is detected inside ConptyConnection's output callback. Queue the // first command so that callback can return before we call WriteInput // on the same connection. const auto weakLeader = leader->get_weak(); _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, weakLeader]() { if (const auto leader = weakLeader.get(); leader && _leader == leader.get() && - leader->State() == ConnectionState::Connected) + leader->State() == ConnectionState::Connected) { WriteToLeader("refresh-client -C 80x24"); } @@ -31,7 +34,11 @@ namespace winrt::TerminalApp::implementation void HtmSession::DetachLeader(HtmLeaderConnection* leader) { - if (_leader == leader) { _leader = nullptr; _exitHtmMode(); } + if (_leader == leader) + { + _leader = nullptr; + _exitHtmMode(); + } } bool HtmSession::IsActive() const noexcept @@ -42,19 +49,29 @@ namespace winrt::TerminalApp::implementation bool HtmSession::IsHtmConnection(const ITerminalConnection& connection) const { - if (const auto leader{ connection.try_as() }) return leader.get() == _leader; - if (const auto follower{ connection.try_as() }) return _followers.contains(follower->PaneId()); + if (const auto leader{ connection.try_as() }) + return leader.get() == _leader; + if (const auto follower{ connection.try_as() }) + return _followers.contains(follower->PaneId()); return false; } void HtmSession::RegisterFollower(HtmFollowerConnection* follower) { - if (follower && !follower->PaneId().empty()) { std::lock_guard lock{ _mutex }; _followers[follower->PaneId()] = follower; } + if (follower && !follower->PaneId().empty()) + { + std::lock_guard lock{ _mutex }; + _followers[follower->PaneId()] = follower; + } } void HtmSession::UnregisterFollower(HtmFollowerConnection* follower) { - if (follower) { std::lock_guard lock{ _mutex }; _followers.erase(follower->PaneId()); } + if (follower) + { + std::lock_guard lock{ _mutex }; + _followers.erase(follower->PaneId()); + } } void HtmSession::WriteToLeader(std::string_view command) @@ -64,14 +81,16 @@ namespace winrt::TerminalApp::implementation std::string line{ command }; // A Windows console in line-input mode submits on CR, not LF. // htmd accepts CR, LF, and CRLF as tmux command delimiters. - if (line.empty() || (line.back() != '\r' && line.back() != '\n')) line.push_back('\r'); + if (line.empty() || (line.back() != '\r' && line.back() != '\n')) + line.push_back('\r'); _leader->WriteRaw(line); } } void HtmSession::SendKeys(std::string_view paneId, std::string_view utf8) { - if (paneId.empty()) return; + if (paneId.empty()) + return; static constexpr char hex[] = "0123456789abcdef"; std::string command{ "send-keys -H -t " }; command += paneId; @@ -89,7 +108,8 @@ namespace winrt::TerminalApp::implementation if (line.rfind("%output ", 0) == 0) { const auto first = line.find(' ', 8); - if (first != std::string_view::npos) _appendToPane(std::string{ line.substr(8, first - 8) }, UnescapeControlOutput(line.substr(first + 1))); + if (first != std::string_view::npos) + _appendToPane(std::string{ line.substr(8, first - 8) }, UnescapeControlOutput(line.substr(first + 1))); return; } if (line.rfind("%window-pane-changed ", 0) == 0) @@ -154,23 +174,40 @@ namespace winrt::TerminalApp::implementation } return; } - if (line.rfind("%begin ", 0) == 0) { _inReply = true; _replyLines.clear(); return; } - if (line.rfind("%end ", 0) == 0 || line.rfind("%error ", 0) == 0) { _finishReply(); return; } - if (line == "%exit") { _exitHtmMode(); return; } - if (_inReply) _replyLines.emplace_back(line); + if (line.rfind("%begin ", 0) == 0) + { + _inReply = true; + _replyLines.clear(); + return; + } + if (line.rfind("%end ", 0) == 0 || line.rfind("%error ", 0) == 0) + { + _finishReply(); + return; + } + if (line == "%exit") + { + _exitHtmMode(); + return; + } + if (_inReply) + _replyLines.emplace_back(line); } void HtmSession::_finishReply() { _inReply = false; - if (_replyLines.empty()) return; + if (_replyLines.empty()) + return; // -P -F '#{pane_id}' replies with exactly the new %pane identifier. const auto id = _replyLines.front(); - if (id.empty() || id.front() != '%') return; + if (id.empty() || id.front() != '%') + return; HtmFollowerConnection* follower = nullptr; { std::lock_guard lock{ _mutex }; - if (_pendingFollowers.empty()) return; + if (_pendingFollowers.empty()) + return; follower = _pendingFollowers.front().connection; _pendingFollowers.erase(_pendingFollowers.begin()); } @@ -183,7 +220,8 @@ namespace winrt::TerminalApp::implementation ITerminalConnection HtmSession::CreateFollowerForUserSplit(const std::string& sourcePaneId, bool vertical) { - if (!_leader || sourcePaneId.empty()) return nullptr; + if (!_leader || sourcePaneId.empty()) + return nullptr; auto follower = winrt::make_self(this, ""); { std::lock_guard lock{ _mutex }; @@ -195,7 +233,8 @@ namespace winrt::TerminalApp::implementation ITerminalConnection HtmSession::CreateFollowerForUserTab() { - if (!_leader) return nullptr; + if (!_leader) + return nullptr; auto follower = winrt::make_self(this, ""); { std::lock_guard lock{ _mutex }; @@ -207,9 +246,18 @@ namespace winrt::TerminalApp::implementation bool HtmSession::HandleUserClose(const ITerminalConnection& connection) { - if (_suppressClosePackets || !IsHtmConnection(connection)) return false; - if (const auto follower{ connection.try_as() }) { WriteToLeader("kill-pane -t " + follower->PaneId()); return true; } - if (const auto leader{ connection.try_as() }) { WriteToLeader("kill-pane -t " + leader->PaneId()); return true; } + if (_suppressClosePackets || !IsHtmConnection(connection)) + return false; + if (const auto follower{ connection.try_as() }) + { + WriteToLeader("kill-pane -t " + follower->PaneId()); + return true; + } + if (const auto leader{ connection.try_as() }) + { + WriteToLeader("kill-pane -t " + leader->PaneId()); + return true; + } return false; } @@ -217,9 +265,14 @@ namespace winrt::TerminalApp::implementation { const std::string data{ utf8 }; _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, paneId, data]() { - if (_leader && _leader->PaneId() == paneId) { _leader->InjectOutput(data); return; } + if (_leader && _leader->PaneId() == paneId) + { + _leader->InjectOutput(data); + return; + } std::lock_guard lock{ _mutex }; - if (const auto it = _followers.find(paneId); it != _followers.end() && it->second) it->second->InjectOutput(data); + if (const auto it = _followers.find(paneId); it != _followers.end() && it->second) + it->second->InjectOutput(data); }); } diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index 7140084e877..b553571b6ef 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -88,18 +88,18 @@ namespace winrt::TerminalApp::implementation // This call to _MakePane won't return nullptr, we already checked that // case above with the _maybeElevate call. - if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) - { - // new-window is session-scoped and does not require a source pane. - // Requiring a focused HTM connection here made command-line - // new-tab actions intermittently fall through while focus was - // transitioning after a split. - if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) - { - _CreateNewTabFromPane(_MakePane(newContentArgs, nullptr, follower)); - return S_OK; - } - } + if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) + { + // new-window is session-scoped and does not require a source pane. + // Requiring a focused HTM connection here made command-line + // new-tab actions intermittently fall through while focus was + // transitioning after a split. + if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) + { + _CreateNewTabFromPane(_MakePane(newContentArgs, nullptr, follower)); + return S_OK; + } + } _CreateNewTabFromPane(_MakePane(newContentArgs, nullptr)); return S_OK; } diff --git a/src/cascadia/TerminalSettingsModel/IInheritable.h b/src/cascadia/TerminalSettingsModel/IInheritable.h index aae47f61db5..f25e3e81bb0 100644 --- a/src/cascadia/TerminalSettingsModel/IInheritable.h +++ b/src/cascadia/TerminalSettingsModel/IInheritable.h @@ -136,7 +136,7 @@ private: \ return std::nullopt; \ } \ \ - auto _get##name##OverrideSourceImpl()->decltype(get_strong()) \ + auto _get##name##OverrideSourceImpl() -> decltype(get_strong()) \ { \ /*we have a value*/ \ if (_##name) \ @@ -159,7 +159,7 @@ private: \ } \ \ auto _get##name##OverrideSourceAndValueImpl() \ - ->std::pair \ + -> std::pair \ { \ /*we have a value*/ \ if (_##name) \ From adfd8b9662345baac21ba1bf1f5b02572f476a99 Mon Sep 17 00:00:00 2001 From: Jason Gauci Date: Fri, 4 Sep 2026 11:03:02 -0500 Subject: [PATCH 6/9] Harden HTM follower windows for detach, resize, and tabs. Close native HTM panes via ForceCloseUi plus hosting-page teardown, debounce resize storms, map new-tab/new-window correctly, fix UTF-16 surrogate input, and always tear down HTM followers even when closeOnExit is never. Co-authored-by: Cursor --- .../TerminalApp/AppActionHandlers.cpp | 123 +++- src/cascadia/TerminalApp/HtmConnections.cpp | 279 +++++++- src/cascadia/TerminalApp/HtmConnections.h | 46 +- src/cascadia/TerminalApp/HtmProtocol.h | 310 +++++++++ src/cascadia/TerminalApp/HtmSession.cpp | 640 +++++++++++++++++- src/cascadia/TerminalApp/HtmSession.h | 36 + src/cascadia/TerminalApp/TabManagement.cpp | 36 +- src/cascadia/TerminalApp/TerminalPage.cpp | 276 +++++++- src/cascadia/TerminalApp/TerminalPage.h | 8 +- .../TerminalApp/TerminalPaneContent.cpp | 19 +- src/cascadia/ut_app/HtmProtocolTests.cpp | 61 ++ 11 files changed, 1700 insertions(+), 134 deletions(-) diff --git a/src/cascadia/TerminalApp/AppActionHandlers.cpp b/src/cascadia/TerminalApp/AppActionHandlers.cpp index b6cf83d7fa7..834a451f299 100644 --- a/src/cascadia/TerminalApp/AppActionHandlers.cpp +++ b/src/cascadia/TerminalApp/AppActionHandlers.cpp @@ -5,6 +5,7 @@ #include "App.h" #include "TerminalPage.h" +#include "HtmConnections.h" #include "ScratchpadContent.h" #include "../WinRTUtils/inc/WtExeUtils.h" #include "../../types/inc/utils.hpp" @@ -64,6 +65,18 @@ namespace winrt::TerminalApp::implementation void TerminalPage::_HandleDuplicateTab(const IInspectable& /*sender*/, const ActionEventArgs& args) { + if (Feature_HtmIntegration::IsEnabled()) + { + if (auto* session{ _HtmSessionForConnection(_HtmFocusedConnection()) }) + { + if (const auto follower{ session->CreateFollowerForUserTab() }) + { + _HtmOpenFollowerAsTab(follower); + args.Handled(true); + return; + } + } + } _DuplicateFocusedTab(); args.Handled(true); } @@ -96,6 +109,16 @@ namespace winrt::TerminalApp::implementation void TerminalPage::_HandleClosePane(const IInspectable& /*sender*/, const ActionEventArgs& args) { + if (Feature_HtmIntegration::IsEnabled()) + { + if (const auto conn{ _HtmFocusedConnection() }) + { + if (auto* session{ _HtmSessionForConnection(conn) }) + { + session->HandleUserClose(conn); + } + } + } _CloseFocusedPane(); args.Handled(true); } @@ -274,36 +297,71 @@ namespace winrt::TerminalApp::implementation } else if (const auto& realArgs = args.ActionArgs().try_as()) { - if (_shouldBailForInvalidProfileIndex(_settings, realArgs.ContentArgs())) - { - args.Handled(false); - return; - } - const auto& duplicateFromTab{ realArgs.SplitMode() == SplitType::Duplicate ? _GetFocusedTab() : nullptr }; const auto& activeTab{ _senderOrFocusedTab(sender) }; - if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) + // Intercept before the invalid-profile bail-out so a command-line + // duplicate split on an HTM follower still talks to htmd. + // Prefer any HTM connection in this window: CLI ``-w last`` often + // arrives before the TermControl is the XAML focus target. + if (Feature_HtmIntegration::IsEnabled()) { - const auto focusedConn = _HtmFocusedConnection(); - const auto sourceId = _HtmPaneIdFromConnection(focusedConn); - if (!sourceId.empty()) + const auto htmConn{ _HtmAnyConnectionInWindow() }; + auto* session = _HtmSessionForConnection(htmConn); + if (!session && _htmSession && _htmSession->IsActive()) { + session = _htmSession.get(); + } + if (session) + { + auto sourceId = _HtmPaneIdFromConnection(htmConn); + if (sourceId.empty() || !session->HasFollower(sourceId)) + { + sourceId = session->LeaderPaneId(); + } + if (!session->HasFollower(sourceId)) + { + sourceId = session->FirstLiveFollowerPaneId(); + } + if (sourceId.empty()) + { + // htmd still owns panes after a local map miss (e.g. UI + // collapsed and UnregisterFollower raced); target root. + sourceId = "%0"; + } const auto direction = realArgs.SplitDirection(); const bool vertical = direction != SplitDirection::Up && direction != SplitDirection::Down; - if (const auto follower{ _htmSession->CreateFollowerForUserSplit(sourceId, vertical) }) + if (const auto follower{ session->CreateFollowerForUserSplit(sourceId, vertical) }) { - _SplitPane(activeTab, - direction, - realArgs.SplitSize(), - _MakePane(realArgs.ContentArgs(), duplicateFromTab, follower)); + // Prefer splitting the focused follower tab; otherwise + // locate the source pane across windows. + if (htmConn && htmConn.try_as() && session->HasFollower(sourceId) && + _HtmPaneIdFromConnection(htmConn) == sourceId) + { + _SplitPane(activeTab, + direction, + realArgs.SplitSize(), + _MakePane(realArgs.ContentArgs(), duplicateFromTab, follower)); + } + else + { + _HtmSplitExisting(sourceId, follower, vertical); + } args.Handled(true); return; } + args.Handled(true); + return; } } + if (_shouldBailForInvalidProfileIndex(_settings, realArgs.ContentArgs())) + { + args.Handled(false); + return; + } + _SplitPane(activeTab, realArgs.SplitDirection(), // This is safe, we're already filtering so the value is (0, 1) @@ -480,19 +538,6 @@ namespace winrt::TerminalApp::implementation const ActionEventArgs& args) { const auto realArgs = args ? args.ActionArgs().try_as() : nullptr; - if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) - { - const auto focusedConn = _HtmFocusedConnection(); - if (!_HtmPaneIdFromConnection(focusedConn).empty()) - { - if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) - { - _CreateNewTabFromPane(_MakePane(realArgs ? realArgs.ContentArgs() : nullptr, nullptr, follower)); - args.Handled(true); - return; - } - } - } if (args == nullptr) { @@ -943,6 +988,28 @@ namespace winrt::TerminalApp::implementation void TerminalPage::_HandleNewWindow(const IInspectable& /*sender*/, const ActionEventArgs& actionArgs) { + if (Feature_HtmIntegration::IsEnabled()) + { + if (auto* session{ _HtmSessionForConnection(_HtmFocusedConnection()) }) + { + if (const auto follower{ session->CreateFollowerForUserTab() }) + { + _HtmOpenFollowerAsWindow(follower); + actionArgs.Handled(true); + return; + } + } + else if (_htmSession && _htmSession->IsActive()) + { + if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) + { + _HtmOpenFollowerAsWindow(follower); + actionArgs.Handled(true); + return; + } + } + } + INewContentArgs newContentArgs{ nullptr }; // If the caller provided NewTerminalArgs, then try to use those if (actionArgs) diff --git a/src/cascadia/TerminalApp/HtmConnections.cpp b/src/cascadia/TerminalApp/HtmConnections.cpp index 9233c4fe298..cbbe745667d 100644 --- a/src/cascadia/TerminalApp/HtmConnections.cpp +++ b/src/cascadia/TerminalApp/HtmConnections.cpp @@ -5,6 +5,8 @@ #include "HtmConnections.h" #include "HtmSession.h" +#include + using namespace winrt::Microsoft::Terminal::TerminalConnection; using namespace ::Microsoft::Terminal::Htm; @@ -38,25 +40,23 @@ namespace winrt::TerminalApp::implementation { if (_htmMode) { - const auto utf8 = til::u16u8(winrt_array_to_wstring_view(data)); - if (utf8 == "\x1b[I" || utf8 == "\x1b[O") + // Stateful conversion: KEYEVENTF_UNICODE may deliver one surrogate + // per WriteInput; til::u16u8 without state would emit CESU-8. + const auto utf8 = til::u16u8(winrt_array_to_wstring_view(data), _u16ToUtf8); + if (utf8.empty() || utf8 == "\x1b[I" || utf8 == "\x1b[O") { return; } - // With VT input enabled, Windows Terminal reports Escape as an - // enhanced-key sequence whose first codepoint is 27. Plain VT - // input still arrives as the single ESC byte. - if (utf8 == "\x1b" || utf8.starts_with("\x1b[27;")) + if (!_session) { - _session->WriteToLeader("detach-client"); return; } - if (utf8 == "x" || utf8.starts_with("\x1b[88;0;120;1;")) + const auto keys = DecodeWin32InputMode(utf8, _win32Decode); + if (keys.empty()) { - _session->WriteToLeader("kill-server"); return; } - _session->SendKeys(_paneId, utf8); + _session->HandleLeaderInput(keys); return; } _wrapped.WriteInput(data); @@ -65,14 +65,70 @@ namespace winrt::TerminalApp::implementation void HtmLeaderConnection::Resize(uint32_t rows, uint32_t columns) { _wrapped.Resize(rows, columns); - if (_htmMode && !_paneId.empty()) + if (!_htmMode || !_session || rows == 0 || columns == 0) + { + return; + } + uint32_t generation = 0; { - _session->WriteToLeader("refresh-client -C " + std::to_string(columns) + "x" + std::to_string(rows)); + std::lock_guard lock{ _stateMutex }; + if (_rows == rows && _cols == columns) + { + return; + } + _rows = rows; + _cols = columns; + generation = ++_resizeGeneration; } + const auto weak = get_weak(); + winrt::Windows::System::Threading::ThreadPoolTimer::CreateTimer( + [weak, generation](const auto&) { + if (const auto self = weak.get()) + { + uint32_t current = 0; + { + std::lock_guard lock{ self->_stateMutex }; + current = self->_resizeGeneration; + } + if (current == generation) + { + self->_flushPendingClientSize(); + } + } + }, + std::chrono::milliseconds{ 75 }); + } + + void HtmLeaderConnection::_flushPendingClientSize() + { + HtmSession* session = nullptr; + uint32_t rows = 0; + uint32_t cols = 0; + { + std::lock_guard lock{ _stateMutex }; + if (_closed || !_htmMode || !_session || _rows == 0 || _cols == 0) + { + return; + } + if (_rows == _flushedRows && _cols == _flushedCols) + { + return; + } + _flushedRows = _rows; + _flushedCols = _cols; + session = _session; + rows = _rows; + cols = _cols; + } + session->WriteToLeader("refresh-client -C " + std::to_string(cols) + "x" + std::to_string(rows)); } void HtmLeaderConnection::Close() { + { + std::lock_guard lock{ _stateMutex }; + ++_resizeGeneration; + } _closed = true; if (_session && _htmMode) { @@ -106,13 +162,20 @@ namespace winrt::TerminalApp::implementation // Pane input, resizes, and app actions can arrive on different UI and // connection threads. Keep each HTM frame in one ConPTY write so a // resize cannot splice itself into a key or split packet. - std::lock_guard lock{ _writeMutex }; - if (!_wrapped) + try { - return; + std::lock_guard lock{ _writeMutex }; + if (_closed || !_wrapped) + { + return; + } + const auto wide = til::u8u16(bytes); + _wrapped.WriteInput(winrt_wstring_to_array_view(wide)); + } + catch (...) + { + // ConPTY may already be gone during htmd teardown; never abort. } - const auto wide = til::u8u16(bytes); - _wrapped.WriteInput(winrt_wstring_to_array_view(wide)); } void HtmLeaderConnection::InjectOutput(std::string_view utf8) @@ -125,12 +188,33 @@ namespace winrt::TerminalApp::implementation TerminalOutput.raise(winrt_wstring_to_array_view(wide)); } + void HtmLeaderConnection::ForceCloseClient() + { + _htmMode = false; + _session = nullptr; + _outputRevoker.revoke(); + _stateChangedRevoker.revoke(); + if (_wrapped) + { + _wrapped.Close(); + _wrapped = nullptr; + } + _closed = true; + StateChanged.raise(*this, nullptr); + } + void HtmLeaderConnection::_OutputHandler(const winrt::array_view str) { const auto utf8 = til::u16u8(winrt_array_to_wstring_view(str)); + const auto carrier = DecodeConPtyHtmCarrier(_carrierPending, utf8); + _carrierPending = carrier.pending; + if (carrier.decoded.empty()) + { + return; + } if (_htmMode) { - if (utf8.find(TmuxControlSt) != std::string::npos) + if (carrier.decoded.find(TmuxControlSt) != std::string::npos) { _htmMode = false; if (_session) @@ -139,11 +223,11 @@ namespace winrt::TerminalApp::implementation } return; } - _ProcessHtmBytes(utf8); + _ProcessHtmBytes(carrier.decoded); return; } - _pendingInit.append(utf8); + _pendingInit.append(carrier.decoded); const auto marker = _pendingInit.find(TmuxControlDcs); if (marker == std::string::npos) { @@ -196,6 +280,7 @@ namespace winrt::TerminalApp::implementation { HtmSession* session = nullptr; std::string paneId; + std::wstring pendingWide; uint32_t rows = 0; uint32_t cols = 0; { @@ -205,36 +290,117 @@ namespace winrt::TerminalApp::implementation paneId = _paneId; rows = _rows; cols = _cols; + if (!_pendingOutput.empty()) + { + pendingWide = til::u8u16(_pendingOutput); + _pendingOutput.clear(); + } } StateChanged.raise(*this, nullptr); if (session) { session->RegisterFollower(this); - if (!paneId.empty()) + if (!paneId.empty() && rows > 0 && cols > 0) { + { + std::lock_guard lock{ _stateMutex }; + _flushedRows = rows; + _flushedCols = cols; + } session->WriteToLeader("resize-pane -t " + paneId + " -x " + std::to_string(cols) + " -y " + std::to_string(rows)); } } + if (!pendingWide.empty()) + { + TerminalOutput.raise(winrt_wstring_to_array_view(pendingWide)); + } } void HtmFollowerConnection::WriteInput(const winrt::array_view data) { - if (!_session) + if (!_session || _closed) + { + return; + } + // Stateful conversion: KEYEVENTF_UNICODE may deliver one surrogate + // per WriteInput; til::u16u8 without state would emit CESU-8. + const auto utf8 = til::u16u8(winrt_array_to_wstring_view(data), _u16ToUtf8); + if (utf8.empty() || utf8 == "\x1b[I" || utf8 == "\x1b[O") { return; } - const auto utf8 = til::u16u8(winrt_array_to_wstring_view(data)); - _session->SendKeys(_paneId, utf8); + const auto keys = DecodeWin32InputMode(utf8, _win32Decode); + if (keys.empty()) + { + return; + } + _session->SendKeys(_paneId, keys); } void HtmFollowerConnection::Resize(uint32_t rows, uint32_t columns) { - _rows = rows; - _cols = columns; - if (_session && !_paneId.empty()) + // TermControl may report 0x0 during first layout; never push that to htmd. + if (rows == 0 || columns == 0) + { + return; + } + uint32_t generation = 0; { - _session->WriteToLeader("resize-pane -t " + _paneId + " -x " + std::to_string(columns) + " -y " + std::to_string(rows)); + std::lock_guard lock{ _stateMutex }; + if (_rows == rows && _cols == columns) + { + return; + } + _rows = rows; + _cols = columns; + // Split layout settles through dozens of intermediate sizes. Each + // ConPTY resize tends to inject blank lines into the pane scrollback. + generation = ++_resizeGeneration; } + const auto weak = get_weak(); + winrt::Windows::System::Threading::ThreadPoolTimer::CreateTimer( + [weak, generation](const auto&) { + if (const auto self = weak.get()) + { + uint32_t current = 0; + { + std::lock_guard lock{ self->_stateMutex }; + current = self->_resizeGeneration; + } + if (current == generation) + { + self->_flushPendingResize(); + } + } + }, + // ~75ms trailing debounce covers WT split layout animation. + std::chrono::milliseconds{ 75 }); + } + + void HtmFollowerConnection::_flushPendingResize() + { + HtmSession* session = nullptr; + std::string paneId; + uint32_t rows = 0; + uint32_t cols = 0; + { + std::lock_guard lock{ _stateMutex }; + if (_closed || !_session || _paneId.empty() || _rows == 0 || _cols == 0) + { + return; + } + if (_rows == _flushedRows && _cols == _flushedCols) + { + return; + } + _flushedRows = _rows; + _flushedCols = _cols; + session = _session; + paneId = _paneId; + rows = _rows; + cols = _cols; + } + session->WriteToLeader("resize-pane -t " + paneId + " -x " + std::to_string(cols) + " -y " + std::to_string(rows)); } void HtmFollowerConnection::SetPaneId(std::string paneId) @@ -245,12 +411,14 @@ namespace winrt::TerminalApp::implementation { std::lock_guard lock{ _stateMutex }; _paneId = std::move(paneId); - if (_started && !_paneId.empty()) + if (_started && !_paneId.empty() && _rows > 0 && _cols > 0) { session = _session; paneId = _paneId; rows = _rows; cols = _cols; + _flushedRows = rows; + _flushedCols = cols; } } if (session) @@ -270,16 +438,65 @@ namespace winrt::TerminalApp::implementation _session->UnregisterFollower(this); } _session = nullptr; + _closed = true; StateChanged.raise(*this, nullptr); } + void HtmFollowerConnection::ForceCloseUi() + { + { + std::lock_guard lock{ _stateMutex }; + _suppressClosePacket = true; + _session = nullptr; + _closed = true; + _pendingOutput.clear(); + // Cancel trailing resize debounce timers. + ++_resizeGeneration; + } + try + { + StateChanged.raise(*this, nullptr); + } + catch (...) + { + } + } + void HtmFollowerConnection::InjectOutput(std::string_view utf8) { if (utf8.empty()) { return; } - const auto wide = til::u8u16(utf8); - TerminalOutput.raise(winrt_wstring_to_array_view(wide)); + try + { + std::wstring wide; + { + std::lock_guard lock{ _stateMutex }; + if (_closed) + { + return; + } + if (!_started) + { + _pendingOutput.append(utf8); + return; + } + wide = til::u8u16(utf8); + if (_closed) + { + return; + } + } + if (_closed) + { + return; + } + TerminalOutput.raise(winrt_wstring_to_array_view(wide)); + } + catch (...) + { + // TermControl may already be tearing down during detach. + } } } diff --git a/src/cascadia/TerminalApp/HtmConnections.h b/src/cascadia/TerminalApp/HtmConnections.h index f2e438f93c5..c33bf56f818 100644 --- a/src/cascadia/TerminalApp/HtmConnections.h +++ b/src/cascadia/TerminalApp/HtmConnections.h @@ -7,6 +7,7 @@ #include #include +#include #include @@ -31,7 +32,9 @@ namespace winrt::TerminalApp::implementation void WriteRaw(std::string_view bytes); void InjectOutput(std::string_view utf8); + void ForceCloseClient(); bool InHtmMode() const noexcept { return _htmMode; } + HtmSession* Session() const noexcept { return _session; } void SetPaneId(std::string paneId) { std::lock_guard lock{ _stateMutex }; @@ -57,11 +60,22 @@ namespace winrt::TerminalApp::implementation HtmSession* _session{ nullptr }; bool _htmMode{ false }; std::string _pendingInit; + std::string _carrierPending; std::string _htmBuffer; mutable std::mutex _stateMutex; std::string _paneId; std::mutex _writeMutex; bool _closed{ false }; + // SendInput KEYEVENTF_UNICODE delivers one UTF-16 code unit per call; + // hold high surrogates across WriteInput so emoji becomes real UTF-8. + til::u16state _u16ToUtf8; + ::Microsoft::Terminal::Htm::Win32InputDecodeState _win32Decode; + uint32_t _rows{ 24 }; + uint32_t _cols{ 80 }; + uint32_t _flushedRows{ 0 }; + uint32_t _flushedCols{ 0 }; + uint32_t _resizeGeneration{ 0 }; + void _flushPendingClientSize(); }; class HtmFollowerConnection : public winrt::implements @@ -78,14 +92,21 @@ namespace winrt::TerminalApp::implementation winrt::guid SessionId() const noexcept { return {}; } winrt::Microsoft::Terminal::TerminalConnection::ConnectionState State() const noexcept { - return winrt::Microsoft::Terminal::TerminalConnection::ConnectionState::Connected; + return _closed ? winrt::Microsoft::Terminal::TerminalConnection::ConnectionState::Closed : + winrt::Microsoft::Terminal::TerminalConnection::ConnectionState::Connected; } + HtmSession* Session() const noexcept { return _session; } std::string PaneId() const noexcept { std::lock_guard lock{ _stateMutex }; return _paneId; } + bool IsClosed() const noexcept + { + std::lock_guard lock{ _stateMutex }; + return _closed; + } void SetPaneId(std::string paneId); void InjectOutput(std::string_view utf8); void SetSuppressClosePacket(bool value) noexcept @@ -93,6 +114,18 @@ namespace winrt::TerminalApp::implementation std::lock_guard lock{ _stateMutex }; _suppressClosePacket = value; } + // Stop accepting output/input without raising StateChanged; the page + // still owns the TermControl and will close it via _HtmClosePane. + void SilenceForDetach() noexcept + { + std::lock_guard lock{ _stateMutex }; + _suppressClosePacket = true; + _session = nullptr; + _closed = true; + _pendingOutput.clear(); + } + void ForceCloseUi(); + void _flushPendingResize(); til::event TerminalOutput; til::typed_event StateChanged; @@ -103,7 +136,18 @@ namespace winrt::TerminalApp::implementation std::string _paneId; bool _started{ false }; bool _suppressClosePacket{ false }; + bool _closed{ false }; + std::string _pendingOutput; + // SendInput KEYEVENTF_UNICODE delivers one UTF-16 code unit per call; + // hold high surrogates across WriteInput so emoji becomes real UTF-8. + til::u16state _u16ToUtf8; + ::Microsoft::Terminal::Htm::Win32InputDecodeState _win32Decode; uint32_t _rows{ 24 }; uint32_t _cols{ 80 }; + // Last size pushed to htmd. Split layout animates through many + // intermediate sizes; each ConPTY resize injects blank lines. + uint32_t _flushedRows{ 0 }; + uint32_t _flushedCols{ 0 }; + uint32_t _resizeGeneration{ 0 }; }; } diff --git a/src/cascadia/TerminalApp/HtmProtocol.h b/src/cascadia/TerminalApp/HtmProtocol.h index 01f7dc6616a..c230dfeda68 100644 --- a/src/cascadia/TerminalApp/HtmProtocol.h +++ b/src/cascadia/TerminalApp/HtmProtocol.h @@ -21,6 +21,115 @@ namespace Microsoft::Terminal::Htm // bytes after DCS are ordinary newline-delimited tmux control records. inline constexpr std::string_view TmuxControlDcs{ "\x1bP1000p" }; inline constexpr std::string_view TmuxControlSt{ "\x1b\\" }; + // iTerm2's tmux -CC gateway banner. WezTerm prints the same text. + inline constexpr std::string_view TmuxCommandMenu{ + "\r\n** tmux mode started **\r\n\r\n" + "Command Menu\r\n" + "----------------------------\r\n" + "esc Detach cleanly.\r\n" + " X Force-quit tmux mode.\r\n" + " L Toggle logging.\r\n" + " C Run tmux command.\r\n" + }; + // ConPTY strips DCS. EternalTerminal's Windows htm client carries control + // bytes as CSI ?777;b0;b1;...q (at most 15 payload bytes per sequence). + inline constexpr std::string_view ConPtyHtmCarrierPrefix{ "\x1b[?777" }; + inline size_t LongestInitPrefix(std::string_view data, std::string_view needle); + + inline std::string EncodeConPtyHtmCarrier(std::string_view bytes) + { + std::string out; + constexpr size_t chunkSize = 15; + for (size_t offset = 0; offset < bytes.size(); offset += chunkSize) + { + const auto end = std::min(bytes.size(), offset + chunkSize); + out.append(ConPtyHtmCarrierPrefix); + for (size_t i = offset; i < end; ++i) + { + out.push_back(';'); + out += std::to_string(static_cast(bytes[i])); + } + out.push_back('q'); + } + return out; + } + + struct CarrierDecodeResult + { + std::string decoded; + std::string pending; + }; + + inline CarrierDecodeResult DecodeConPtyHtmCarrier(std::string_view pending, std::string_view incoming) + { + std::string data; + data.reserve(pending.size() + incoming.size()); + data.append(pending); + data.append(incoming); + CarrierDecodeResult result; + size_t i = 0; + while (i < data.size()) + { + const auto pos = data.find(ConPtyHtmCarrierPrefix, i); + if (pos == std::string::npos) + { + const auto keep = LongestInitPrefix(std::string_view{ data }.substr(i), ConPtyHtmCarrierPrefix); + result.decoded.append(data.substr(i, data.size() - i - keep)); + result.pending = data.substr(data.size() - keep); + return result; + } + result.decoded.append(data.substr(i, pos - i)); + size_t cursor = pos + ConPtyHtmCarrierPrefix.size(); + std::string payload; + bool complete = false; + bool invalid = false; + while (cursor < data.size()) + { + if (data[cursor] == 'q') + { + complete = true; + ++cursor; + break; + } + if (data[cursor] != ';') + { + invalid = true; + break; + } + ++cursor; + if (cursor >= data.size()) + { + break; + } + if (data[cursor] < '0' || data[cursor] > '9') + { + invalid = true; + break; + } + int value = 0; + while (cursor < data.size() && data[cursor] >= '0' && data[cursor] <= '9') + { + value = value * 10 + (data[cursor] - '0'); + ++cursor; + } + payload.push_back(static_cast(value & 0xFF)); + } + if (invalid) + { + result.decoded.push_back(data[pos]); + i = pos + 1; + continue; + } + if (!complete) + { + result.pending = data.substr(pos); + return result; + } + result.decoded.append(payload); + i = cursor; + } + return result; + } inline std::string UnescapeControlOutput(std::string_view input) { @@ -46,6 +155,207 @@ namespace Microsoft::Terminal::Htm return result; } + // Encode a Unicode code point as UTF-8 (rejects surrogates / out-of-range). + inline void AppendUtf8CodePoint(std::string& out, char32_t cp) + { + if (cp < 0x80) + { + out.push_back(static_cast(cp)); + } + else if (cp < 0x800) + { + out.push_back(static_cast(0xC0 | (cp >> 6))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } + else if (cp < 0xD800 || (cp > 0xDFFF && cp < 0x10000)) + { + out.push_back(static_cast(0xE0 | (cp >> 12))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } + else if (cp <= 0x10FFFF) + { + out.push_back(static_cast(0xF0 | (cp >> 18))); + out.push_back(static_cast(0x80 | ((cp >> 12) & 0x3F))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } + } + + // KEYEVENTF_UNICODE may deliver one UTF-16 code unit per win32-input-mode + // record; hold an unpaired high surrogate across DecodeWin32InputMode calls. + struct Win32InputDecodeState + { + char16_t pendingHigh{}; + }; + + // Windows Terminal's win32-input-mode: ESC [ vk ; sc ; uc ; kd ; cs ; rc _ + inline std::string DecodeWin32InputMode(std::string_view utf8, Win32InputDecodeState& state) + { + std::string out; + size_t i = 0; + while (i < utf8.size()) + { + if (utf8.size() - i >= 2 && utf8[i] == '\x1b' && utf8[i + 1] == '[') + { + const auto end = utf8.find('_', i + 2); + if (end != std::string_view::npos) + { + const auto body = utf8.substr(i + 2, end - (i + 2)); + int fields[6] = {}; + int count = 0; + size_t p = 0; + while (p < body.size() && count < 6) + { + int value = 0; + while (p < body.size() && body[p] >= '0' && body[p] <= '9') + { + value = value * 10 + (body[p] - '0'); + ++p; + } + fields[count++] = value; + if (p < body.size() && body[p] == ';') + { + ++p; + } + } + i = end + 1; + if (count >= 4) + { + const int vk = fields[0]; + const int uc = fields[2]; + const int keyDown = fields[3]; + if (keyDown != 1) + { + continue; + } + if (uc > 0) + { + const auto unit = static_cast(uc); + if (unit >= 0xD800 && unit <= 0xDBFF) + { + state.pendingHigh = static_cast(unit); + continue; + } + if (unit >= 0xDC00 && unit <= 0xDFFF) + { + if (state.pendingHigh) + { + const char32_t cp = 0x10000 + + ((static_cast(state.pendingHigh) - 0xD800) << 10) + + (unit - 0xDC00); + state.pendingHigh = 0; + AppendUtf8CodePoint(out, cp); + } + continue; + } + state.pendingHigh = 0; + AppendUtf8CodePoint(out, unit); + } + else if (vk == 0x0D) + { + out.push_back('\r'); + } + else if (vk == 0x08) + { + out.push_back('\x7f'); + } + else if (vk == 0x1B) + { + out.push_back('\x1b'); + } + } + continue; + } + } + out.append(utf8.substr(i)); + break; + } + return out; + } + + inline std::string DecodeWin32InputMode(std::string_view utf8) + { + Win32InputDecodeState state; + return DecodeWin32InputMode(utf8, state); + } + + // Collect pane ids from a tmux window_layout body (checksum optional). + // Leaves look like WxH,X,Y,id; splits use { } / [ ] and are skipped. + inline std::vector PaneIdsFromTmuxLayout(std::string_view layout) + { + std::vector ids; + size_t i = 0; + while (i < layout.size()) + { + // Find "NxM," size prefix. + const auto xPos = layout.find('x', i); + if (xPos == std::string_view::npos || xPos == i) + { + break; + } + bool digitsBefore = true; + for (size_t j = i; j < xPos; ++j) + { + if (layout[j] < '0' || layout[j] > '9') + { + digitsBefore = false; + break; + } + } + if (!digitsBefore) + { + ++i; + continue; + } + size_t p = xPos + 1; + auto readNum = [&](size_t& pos) -> bool { + if (pos >= layout.size() || layout[pos] < '0' || layout[pos] > '9') + { + return false; + } + while (pos < layout.size() && layout[pos] >= '0' && layout[pos] <= '9') + { + ++pos; + } + return true; + }; + if (!readNum(p) || p >= layout.size() || layout[p] != ',') + { + i = xPos + 1; + continue; + } + ++p; // X + if (!readNum(p) || p >= layout.size() || layout[p] != ',') + { + i = xPos + 1; + continue; + } + ++p; // Y + if (!readNum(p) || p >= layout.size()) + { + i = xPos + 1; + continue; + } + if (layout[p] == ',' ) + { + ++p; + const size_t idStart = p; + if (!readNum(p) || idStart == p) + { + i = xPos + 1; + continue; + } + ids.push_back("%" + std::string{ layout.substr(idStart, p - idStart) }); + i = p; + continue; + } + // Split container: WxH,X,Y{...} or [...] + i = p; + } + return ids; + } + inline constexpr char InsertKeys = '1'; inline constexpr char InitState = '2'; inline constexpr char ClientClosePane = '3'; diff --git a/src/cascadia/TerminalApp/HtmSession.cpp b/src/cascadia/TerminalApp/HtmSession.cpp index 0964c74a3cd..b453a173ec4 100644 --- a/src/cascadia/TerminalApp/HtmSession.cpp +++ b/src/cascadia/TerminalApp/HtmSession.cpp @@ -6,11 +6,104 @@ #include "HtmConnections.h" #include "TerminalPage.h" +#include + using namespace winrt::Microsoft::Terminal::TerminalConnection; using namespace ::Microsoft::Terminal::Htm; namespace winrt::TerminalApp::implementation { + void HtmSession::SetNativeHostPage(TerminalPage* page) noexcept + { + std::lock_guard lock{ _mutex }; + if (!_nativeHostPage && page) + { + _nativeHostPage = page->get_weak(); + } + } + + TerminalPage* HtmSession::NativeHostPage() const noexcept + { + std::lock_guard lock{ _mutex }; + if (const auto host = _nativeHostPage.get()) + { + return host.get(); + } + return nullptr; + } + + void HtmSession::ClearNativeHostPage(TerminalPage* page) noexcept + { + std::lock_guard lock{ _mutex }; + if (const auto host = _nativeHostPage.get(); host && host.get() == page) + { + _nativeHostPage = nullptr; + } + std::erase_if(_followerPages, [page](const winrt::weak_ref& weak) { + const auto live = weak.get(); + return !live || live.get() == page; + }); + } + + void HtmSession::RegisterFollowerPage(TerminalPage* page) noexcept + { + if (!page) + { + return; + } + std::lock_guard lock{ _mutex }; + for (const auto& weak : _followerPages) + { + if (const auto live = weak.get(); live && live.get() == page) + { + return; + } + } + _followerPages.push_back(page->get_weak()); + if (!_nativeHostPage) + { + _nativeHostPage = page->get_weak(); + } + } + + void HtmSession::OpenFollowerAsTab(const ITerminalConnection& follower) + { + if (!follower) + { + return; + } + winrt::com_ptr host; + { + std::lock_guard lock{ _mutex }; + host = _nativeHostPage.get(); + } + if (host) + { + host->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [host, follower]() { + host->_HtmNewTab(follower); + }); + return; + } + // No native host yet — first tab still needs an OS window to live in. + if (_page) + { + _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, follower]() { + _page->_HtmNewWindow(follower); + }); + } + } + + void HtmSession::OpenFollowerAsWindow(const ITerminalConnection& follower) + { + if (!follower || !_page) + { + return; + } + _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, follower]() { + _page->_HtmNewWindow(follower); + }); + } + HtmSession::HtmSession(TerminalPage* page) : _page{ page } {} void HtmSession::AttachLeader(HtmLeaderConnection* leader) @@ -18,6 +111,7 @@ namespace winrt::TerminalApp::implementation { std::lock_guard lock{ _mutex }; _leader = leader; + _detaching = false; } // DCS is detected inside ConptyConnection's output callback. Queue the // first command so that callback can return before we call WriteInput @@ -27,6 +121,7 @@ namespace winrt::TerminalApp::implementation if (const auto leader = weakLeader.get(); leader && _leader == leader.get() && leader->State() == ConnectionState::Connected) { + leader->InjectOutput(std::string{ TmuxCommandMenu }); WriteToLeader("refresh-client -C 80x24"); } }); @@ -37,6 +132,9 @@ namespace winrt::TerminalApp::implementation if (_leader == leader) { _leader = nullptr; + // Snapshot/close native follower windows before clearing maps so we + // do not leave TermControls writing through a torn-down session. + _closeFollowerUi(); _exitHtmMode(); } } @@ -49,13 +147,44 @@ namespace winrt::TerminalApp::implementation bool HtmSession::IsHtmConnection(const ITerminalConnection& connection) const { - if (const auto leader{ connection.try_as() }) - return leader.get() == _leader; + // Follower before leader: both only implement ITerminalConnection, so a + // leader try_as on a follower can falsely succeed. if (const auto follower{ connection.try_as() }) return _followers.contains(follower->PaneId()); + if (const auto leader{ connection.try_as() }) + return leader.get() == _leader; return false; } + std::string HtmSession::LeaderPaneId() const + { + std::lock_guard lock{ _mutex }; + auto live = [&](const std::string& id) -> bool { + const auto it = _followers.find(id); + return it != _followers.end() && it->second && !it->second->IsClosed(); + }; + if (!_homePaneId.empty() && live(_homePaneId)) + { + return _homePaneId; + } + for (const auto& [id, follower] : _followers) + { + if (follower && !follower->IsClosed()) + { + return id; + } + } + if (_leader) + { + auto id = _leader->PaneId(); + if (!id.empty()) + { + return id; + } + } + return "%0"; + } + void HtmSession::RegisterFollower(HtmFollowerConnection* follower) { if (follower && !follower->PaneId().empty()) @@ -74,17 +203,52 @@ namespace winrt::TerminalApp::implementation } } + bool HtmSession::HasFollower(const std::string& paneId) const + { + std::lock_guard lock{ _mutex }; + const auto it = _followers.find(paneId); + return it != _followers.end() && it->second && !it->second->IsClosed(); + } + + std::string HtmSession::FirstLiveFollowerPaneId() const + { + std::lock_guard lock{ _mutex }; + for (const auto& [id, follower] : _followers) + { + if (follower && !follower->IsClosed()) + { + return id; + } + } + return {}; + } + void HtmSession::WriteToLeader(std::string_view command) { - if (_leader) + if (!_leader) { - std::string line{ command }; - // A Windows console in line-input mode submits on CR, not LF. - // htmd accepts CR, LF, and CRLF as tmux command delimiters. - if (line.empty() || (line.back() != '\r' && line.back() != '\n')) - line.push_back('\r'); - _leader->WriteRaw(line); + return; } + std::string line{ command }; + // A Windows console in line-input mode submits on CR, not LF. + // htmd accepts CR, LF, and CRLF as tmux command delimiters. + if (line.empty() || (line.back() != '\r' && line.back() != '\n')) + line.push_back('\r'); + _logProtocol(">", command); + // Never WriteInput the leader ConPTY on the UI thread or nested inside + // the leader's TerminalOutput handler. Action handlers (split/new-tab) + // and follower Start/Resize otherwise deadlock the window ("Not + // Responding") before htmd ever sees split-window. + // Hold a strong ref: killing htmd can destroy the leader while a queued + // write still runs (Debug abort / UAF during stress teardown). + const auto strongLeader = _leader->get_strong(); + winrt::Windows::System::Threading::ThreadPool::RunAsync( + [strongLeader, line = std::move(line)](const auto&) { + if (strongLeader) + { + strongLeader->WriteRaw(line); + } + }); } void HtmSession::SendKeys(std::string_view paneId, std::string_view utf8) @@ -105,8 +269,13 @@ namespace winrt::TerminalApp::implementation void HtmSession::HandleLine(std::string_view line) { + _logProtocol("<", line); if (line.rfind("%output ", 0) == 0) { + if (_detaching) + { + return; + } const auto first = line.find(' ', 8); if (first != std::string_view::npos) _appendToPane(std::string{ line.substr(8, first - 8) }, UnescapeControlOutput(line.substr(first + 1))); @@ -114,29 +283,59 @@ namespace winrt::TerminalApp::implementation } if (line.rfind("%window-pane-changed ", 0) == 0) { - const auto pos = line.rfind(' '); - if (pos != std::string_view::npos) + // "%window-pane-changed @0 %1" + const auto body = line.substr(21); + const auto space = body.find(' '); + std::string windowId; + std::string paneId; + if (space == std::string_view::npos) { - const std::string paneId{ line.substr(pos + 1) }; - HtmFollowerConnection* follower = nullptr; - { - std::lock_guard lock{ _mutex }; - if (!_pendingFollowers.empty()) - { - follower = _pendingFollowers.front().connection; - _pendingFollowers.erase(_pendingFollowers.begin()); - _followers[paneId] = follower; - } - } - if (follower) + paneId = std::string{ body }; + } + else + { + windowId = std::string{ body.substr(0, space) }; + paneId = std::string{ body.substr(space + 1) }; + } + if (paneId.empty()) + { + return; + } + HtmFollowerConnection* follower = nullptr; + { + std::lock_guard lock{ _mutex }; + if (!windowId.empty()) { - follower->SetPaneId(paneId); + _paneToWindow[paneId] = windowId; } - else if (_leader && _leader->PaneId().empty()) + if (!_pendingFollowers.empty()) { - _leader->SetPaneId(paneId); + follower = _pendingFollowers.front().connection; + _pendingFollowers.erase(_pendingFollowers.begin()); + _followers[paneId] = follower; } } + if (follower) + { + follower->SetPaneId(paneId); + } + else + { + _ensureNativePane(paneId); + } + return; + } + if (line.rfind("%window-renamed ", 0) == 0) + { + // "%window-renamed @0 timeout" + const auto body = line.substr(16); + const auto space = body.find(' '); + if (space != std::string_view::npos && space + 1 < body.size()) + { + const std::string windowId{ body.substr(0, space) }; + const std::string name{ body.substr(space + 1) }; + _renameWindowTabs(windowId, name); + } return; } if (line.rfind("%layout-change ", 0) == 0) @@ -151,12 +350,14 @@ namespace winrt::TerminalApp::implementation if (layoutBegin != std::string_view::npos && layoutEnd != std::string_view::npos) { const auto layout = line.substr(layoutBegin + 1, layoutEnd - layoutBegin - 1); + _syncFollowersToLayout(layout); const auto comma = layout.rfind(','); if (comma != std::string_view::npos && layout.find_first_of("[]{}") == std::string_view::npos) { const std::string paneId{ "%" + std::string{ layout.substr(comma + 1) } }; HtmFollowerConnection* follower = nullptr; + bool splitInFlight = false; { std::lock_guard lock{ _mutex }; if (!_pendingFollowers.empty() && _pendingFollowers.front().isTab) @@ -165,11 +366,21 @@ namespace winrt::TerminalApp::implementation _pendingFollowers.erase(_pendingFollowers.begin()); _followers[paneId] = follower; } + else if (!_pendingFollowers.empty()) + { + // A user split is in flight; wait for %window-pane-changed + // or the -P reply rather than opening a duplicate tab. + splitInFlight = true; + } } if (follower) { follower->SetPaneId(paneId); } + else if (!splitInFlight) + { + _ensureNativePane(paneId); + } } } return; @@ -187,6 +398,7 @@ namespace winrt::TerminalApp::implementation } if (line == "%exit") { + _closeFollowerUi(); _exitHtmMode(); return; } @@ -216,7 +428,13 @@ namespace winrt::TerminalApp::implementation _followers[id] = follower; } - void HtmSession::HandleExitSequence() { _exitHtmMode(); } + void HtmSession::HandleExitSequence() + { + _detaching = true; + _closeFollowerUi(); + _exitHtmMode(); + _detaching = false; + } ITerminalConnection HtmSession::CreateFollowerForUserSplit(const std::string& sourcePaneId, bool vertical) { @@ -265,23 +483,379 @@ namespace winrt::TerminalApp::implementation { const std::string data{ utf8 }; _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, paneId, data]() { - if (_leader && _leader->PaneId() == paneId) + HtmFollowerConnection* follower = nullptr; { - _leader->InjectOutput(data); - return; + std::lock_guard lock{ _mutex }; + if (const auto it = _followers.find(paneId); it != _followers.end()) + { + follower = it->second; + } + } + if (follower) + { + follower->InjectOutput(data); } - std::lock_guard lock{ _mutex }; - if (const auto it = _followers.find(paneId); it != _followers.end() && it->second) - it->second->InjectOutput(data); }); } void HtmSession::_exitHtmMode() { _suppressClosePackets = true; + _commandPrompt = false; + _commandBuffer.clear(); + _homePaneId.clear(); std::lock_guard lock{ _mutex }; _followers.clear(); _pendingFollowers.clear(); + _pendingNativePanes.clear(); _suppressClosePackets = false; } + + void HtmSession::_gatewayPrint(std::string_view text) + { + if (_leader) + { + _leader->InjectOutput(text); + } + } + + void HtmSession::_logProtocol(std::string_view direction, std::string_view line) + { + if (!_protocolLogging) + { + return; + } + if (line.rfind("%output ", 0) == 0) + { + return; + } + std::string text{ "\r\n" }; + text.append(direction); + text.push_back(' '); + auto visible = line; + if (!visible.empty() && (visible.back() == '\r' || visible.back() == '\n')) + { + visible.remove_suffix(1); + } + text.append(visible); + text.append("\r\n"); + _gatewayPrint(text); + } + + void HtmSession::_ensureNativePane(const std::string& paneId) + { + if (paneId.empty() || !_page) + { + return; + } + { + std::lock_guard lock{ _mutex }; + if (_followers.contains(paneId) || _pendingNativePanes.contains(paneId)) + { + return; + } + _pendingNativePanes.insert(paneId); + } + _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, paneId]() { + auto releasePending = wil::scope_exit([&]() { + std::lock_guard lock{ _mutex }; + _pendingNativePanes.erase(paneId); + }); + { + std::lock_guard lock{ _mutex }; + if (!_leader || _followers.contains(paneId)) + { + return; + } + } + auto follower = winrt::make_self(this, paneId); + { + std::lock_guard lock{ _mutex }; + _followers[paneId] = follower.get(); + if (_homePaneId.empty()) + { + _homePaneId = paneId; + } + } + OpenFollowerAsWindow(follower.as()); + }); + } + + void HtmSession::_closeFollowerUi() + { + std::vector followers; + std::vector ids; + std::vector> pages; + { + std::lock_guard lock{ _mutex }; + followers.reserve(_followers.size()); + ids.reserve(_followers.size()); + for (const auto& [id, follower] : _followers) + { + ids.push_back(id); + followers.push_back(follower); + } + // Drop map entries first so late %output cannot re-enter InjectOutput + // after we mark followers closed. + _followers.clear(); + for (const auto& weak : _followerPages) + { + if (auto live = weak.get()) + { + pages.push_back(live); + } + } + _nativeHostPage = nullptr; + _followerPages.clear(); + _paneToWindow.clear(); + } + // Native HTM panes live in other OS windows (RequestNewWindow). Silence + // first, then close on each hosting page (gateway cannot _HtmFindPane them). + for (auto* follower : followers) + { + if (follower) + { + follower->ForceCloseUi(); + } + } + for (const auto& page : pages) + { + page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [page, ids]() { + for (const auto& id : ids) + { + page->_HtmClosePane(id); + } + }); + } + } + + void HtmSession::_syncFollowersToLayout(std::string_view layout) + { + if (_detaching) + { + return; + } + // Drop the optional checksum prefix ("abcd,"). + auto body = layout; + if (body.size() > 5 && body[4] == ',') + { + bool hex = true; + for (size_t i = 0; i < 4; ++i) + { + const char c = body[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) + { + hex = false; + break; + } + } + if (hex) + { + body.remove_prefix(5); + } + } + const auto live = PaneIdsFromTmuxLayout(body); + // A non-empty layout that yields no ids is a parse miss — do not cull + // every follower (that would drop the last pane and block later splits). + if (live.empty()) + { + return; + } + std::unordered_set liveSet(live.begin(), live.end()); + std::vector stale; + std::vector staleFollowers; + { + std::lock_guard lock{ _mutex }; + for (const auto& [id, follower] : _followers) + { + if (!liveSet.contains(id)) + { + stale.push_back(id); + if (follower) + { + staleFollowers.push_back(follower); + } + } + } + for (const auto& id : stale) + { + _followers.erase(id); + _paneToWindow.erase(id); + if (_homePaneId == id) + { + _homePaneId = live.front(); + } + } + } + // ForceCloseUi + hosting-page _HtmClosePane tears down TermControls. + // Leaving silenced inert leaves forced the e2e Cmd+W workaround. + for (auto* follower : staleFollowers) + { + follower->ForceCloseUi(); + } + std::vector> pages; + { + std::lock_guard lock{ _mutex }; + for (const auto& weak : _followerPages) + { + if (auto live = weak.get()) + { + pages.push_back(live); + } + } + } + for (const auto& page : pages) + { + page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [page, stale]() { + for (const auto& id : stale) + { + page->_HtmClosePane(id); + } + }); + } + } + + void HtmSession::_renameWindowTabs(const std::string& windowId, const std::string& name) + { + if (windowId.empty() || name.empty()) + { + return; + } + std::vector paneIds; + std::vector> pages; + { + std::lock_guard lock{ _mutex }; + for (const auto& [paneId, wid] : _paneToWindow) + { + if (wid == windowId) + { + paneIds.push_back(paneId); + } + } + for (const auto& weak : _followerPages) + { + if (auto live = weak.get()) + { + pages.push_back(live); + } + } + } + if (paneIds.empty()) + { + return; + } + const auto title = winrt::hstring{ til::u8u16(name) }; + for (const auto& page : pages) + { + page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [page, paneIds, title]() { + for (const auto& paneId : paneIds) + { + page->_HtmSetTabTitleForPane(paneId, title); + } + }); + } + } + + void HtmSession::_detachCleanly() + { + _detaching = true; + WriteToLeader("detach-client"); + _closeFollowerUi(); + // Drop the leader so a leftover gateway window cannot keep sending + // split-window / send-keys into a dead ConPTY after detach-client. + _leader = nullptr; + _exitHtmMode(); + } + + void HtmSession::_forceQuit() + { + _detaching = true; + _closeFollowerUi(); + if (_leader) + { + auto* leader = _leader; + _leader = nullptr; + leader->ForceCloseClient(); + } + _exitHtmMode(); + _detaching = false; + } + + void HtmSession::_toggleLogging() + { + _protocolLogging = !_protocolLogging; + _gatewayPrint(_protocolLogging ? "\r\ntmux logging enabled\r\n" : "\r\ntmux logging disabled\r\n"); + } + + void HtmSession::_beginCommandPrompt() + { + _commandPrompt = true; + _commandBuffer.clear(); + _gatewayPrint("\r\nEnter a tmux command: "); + } + + void HtmSession::_handleCommandPromptKey(char ch) + { + if (ch == '\r' || ch == '\n') + { + _commandPrompt = false; + _gatewayPrint("\r\n"); + const auto command = std::move(_commandBuffer); + _commandBuffer.clear(); + if (!command.empty()) + { + WriteToLeader(command); + } + return; + } + if (ch == '\x7f' || ch == '\b') + { + if (!_commandBuffer.empty()) + { + _commandBuffer.pop_back(); + _gatewayPrint("\b \b"); + } + return; + } + if (ch >= 32 && ch < 127) + { + _commandBuffer.push_back(ch); + _gatewayPrint(std::string(1, ch)); + } + } + + void HtmSession::HandleLeaderInput(std::string_view keys) + { + for (unsigned char ch : keys) + { + if (_commandPrompt) + { + if (ch == 0x1b) + { + _commandPrompt = false; + _commandBuffer.clear(); + _gatewayPrint("\r\n"); + continue; + } + _handleCommandPromptKey(static_cast(ch)); + continue; + } + if (ch == 0x1b) + { + _detachCleanly(); + } + else if (ch == 'x' || ch == 'X') + { + _forceQuit(); + } + else if (ch == 'l' || ch == 'L') + { + _toggleLogging(); + } + else if (ch == 'c' || ch == 'C') + { + _beginCommandPrompt(); + } + } + } } diff --git a/src/cascadia/TerminalApp/HtmSession.h b/src/cascadia/TerminalApp/HtmSession.h index 344e9dcad80..f6a49ae6701 100644 --- a/src/cascadia/TerminalApp/HtmSession.h +++ b/src/cascadia/TerminalApp/HtmSession.h @@ -7,6 +7,7 @@ #include #include +#include #include namespace winrt::TerminalApp::implementation @@ -22,19 +23,34 @@ namespace winrt::TerminalApp::implementation void DetachLeader(HtmLeaderConnection* leader); bool IsActive() const noexcept; bool IsHtmConnection(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection) const; + std::string LeaderPaneId() const; void RegisterFollower(HtmFollowerConnection* follower); void UnregisterFollower(HtmFollowerConnection* follower); + bool HasFollower(const std::string& paneId) const; + std::string FirstLiveFollowerPaneId() const; void WriteToLeader(std::string_view command); void HandleLine(std::string_view line); void HandleExitSequence(); + void HandleLeaderInput(std::string_view keys); void SendKeys(std::string_view paneId, std::string_view utf8); winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection CreateFollowerForUserSplit(const std::string& sourcePaneId, bool vertical); winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection CreateFollowerForUserTab(); bool HandleUserClose(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection); + // First native HTM pane opens an OS window; later panes become tabs on + // that host (WT-native), instead of one OS window per tmux window. + void SetNativeHostPage(TerminalPage* page) noexcept; + TerminalPage* NativeHostPage() const noexcept; + void ClearNativeHostPage(TerminalPage* page) noexcept; + void RegisterFollowerPage(TerminalPage* page) noexcept; + // WT new-tab → tab on the native HTM host (first one opens an OS window). + void OpenFollowerAsTab(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& follower); + // WT new-window / server new-window → always a new OS window. + void OpenFollowerAsWindow(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& follower); + private: struct PendingFollower { @@ -45,14 +61,34 @@ namespace winrt::TerminalApp::implementation void _appendToPane(const std::string& paneId, std::string_view utf8); void _exitHtmMode(); void _finishReply(); + void _gatewayPrint(std::string_view text); + void _logProtocol(std::string_view direction, std::string_view line); + void _ensureNativePane(const std::string& paneId); + void _closeFollowerUi(); + void _syncFollowersToLayout(std::string_view layout); + void _renameWindowTabs(const std::string& windowId, const std::string& name); + void _detachCleanly(); + void _forceQuit(); + void _toggleLogging(); + void _beginCommandPrompt(); + void _handleCommandPromptKey(char ch); TerminalPage* _page; + winrt::weak_ref _nativeHostPage; + std::vector> _followerPages; + std::unordered_map _paneToWindow; // "%0" -> "@1" HtmLeaderConnection* _leader{ nullptr }; mutable std::mutex _mutex; std::unordered_map _followers; + std::unordered_set _pendingNativePanes; std::vector _pendingFollowers; std::vector _replyLines; + std::string _commandBuffer; + std::string _homePaneId; bool _inReply{ false }; bool _suppressClosePackets{ false }; + bool _protocolLogging{ false }; + bool _commandPrompt{ false }; + bool _detaching{ false }; }; } diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index b553571b6ef..e63de9d8dad 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -88,16 +88,25 @@ namespace winrt::TerminalApp::implementation // This call to _MakePane won't return nullptr, we already checked that // case above with the _maybeElevate call. - if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) + if (Feature_HtmIntegration::IsEnabled()) { - // new-window is session-scoped and does not require a source pane. - // Requiring a focused HTM connection here made command-line - // new-tab actions intermittently fall through while focus was - // transitioning after a split. - if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) + // new-window is session-scoped. Prefer the focused HTM connection's + // session so a native HTM window can spawn another OS window. + if (auto* session{ _HtmSessionForConnection(_HtmFocusedConnection()) }) { - _CreateNewTabFromPane(_MakePane(newContentArgs, nullptr, follower)); - return S_OK; + if (const auto follower{ session->CreateFollowerForUserTab() }) + { + _HtmOpenFollowerAsTab(follower); + return S_OK; + } + } + else if (_htmSession && _htmSession->IsActive()) + { + if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) + { + _HtmOpenFollowerAsTab(follower); + return S_OK; + } } } _CreateNewTabFromPane(_MakePane(newContentArgs, nullptr)); @@ -551,6 +560,17 @@ namespace winrt::TerminalApp::implementation // To close the window here, we need to close the hosting window. if (_tabs.Size() == 0) { + if (Feature_HtmIntegration::IsEnabled()) + { + if (auto* session{ _HtmSessionForConnection(_HtmAnyConnectionInWindow()) }) + { + session->ClearNativeHostPage(this); + } + else if (_htmSession) + { + _htmSession->ClearNativeHostPage(this); + } + } // If we are supposed to save state, make sure we clear it out // if the user manually closed all tabs. // Do this only if we are the last window; the monarch will notice diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index f9b95d7036a..c4c5441cb20 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -821,6 +821,17 @@ namespace winrt::TerminalApp::implementation pane->FinalizeConfigurationGivenDefault(); }); _CreateNewTabFromPane(newPane); + // First HTM follower window becomes the tab host for later new-windows. + if (const auto control{ newPane->GetTerminalControl() }) + { + if (const auto follower{ control.Connection().try_as() }) + { + if (auto* session{ follower->Session() }) + { + session->RegisterFollowerPage(this); + } + } + } } // Method Description: @@ -1670,24 +1681,21 @@ namespace winrt::TerminalApp::implementation valueSet.Insert(L"sessionId", Windows::Foundation::PropertyValue::CreateGuid(id)); } - if (Feature_HtmIntegration::IsEnabled() && _htmSession && _htmSession->IsActive()) - { - if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) - { - return follower; - } - } - connection.Initialize(valueSet); - const auto commandline = settings.Commandline(); - const std::wstring_view commandlineView{ commandline }; - const auto executable = std::filesystem::path{ commandlineView }.filename().wstring(); - const auto isHtmCommand = executable.starts_with(L"htm") || commandlineView.find(L"\\htm.exe") != std::wstring_view::npos; - if (Feature_HtmIntegration::IsEnabled() && _htmSession && isHtmCommand && + if (Feature_HtmIntegration::IsEnabled() && _htmSession && connection.try_as()) { - connection = winrt::make(connection, _htmSession.get()); + std::wstring cmd{ settings.Commandline() }; + for (auto& ch : cmd) + { + ch = til::tolower_ascii(ch); + } + if (cmd.find(L"htm.exe") != std::wstring::npos || cmd == L"htm" || + cmd.ends_with(L"\\htm") || cmd.ends_with(L"/htm")) + { + connection = winrt::make(connection, _htmSession.get()); + } } TraceLoggingWrite( @@ -2983,6 +2991,14 @@ namespace winrt::TerminalApp::implementation _UnZoomIfNeeded(); auto [original, newGuy] = activeTab->SplitPane(*realSplitType, splitSize, newPane); + // Pane::Split returns {nullptr,nullptr} when no leaf is marked active + // (common after focus-tab / new-tab races). Dereferencing newGuy then + // aborts Debug builds; fall back to a new tab with the prepared pane. + if (!original || !newGuy) + { + _CreateNewTabFromPane(newPane); + return; + } // After GH#6586, the control will no longer focus itself // automatically when it's finished being laid out. Manually focus @@ -3847,10 +3863,16 @@ namespace winrt::TerminalApp::implementation // TODO GH#5047 If we cache the NewTerminalArgs, we no longer need to do this. profile = GetClosestProfileForDuplicationOfProfile(profile); controlSettings = Settings::TerminalSettings::CreateWithProfile(_settings, _currentWindowSettings(), profile); - const auto workingDirectory = tabImpl->GetActiveTerminalControl().WorkingDirectory(); - if (Utils::IsValidDirectory(workingDirectory.c_str())) + // HTM follower panes already have a live connection; querying + // WorkingDirectory can block the UI while the gateway ConPTY + // is busy and is unused for virtual followers anyway. + if (!existingConnection) { - controlSettings.DefaultSettings()->StartingDirectory(workingDirectory); + const auto workingDirectory = tabImpl->GetActiveTerminalControl().WorkingDirectory(); + if (Utils::IsValidDirectory(workingDirectory.c_str())) + { + controlSettings.DefaultSettings()->StartingDirectory(workingDirectory); + } } } } @@ -6276,14 +6298,16 @@ namespace winrt::TerminalApp::implementation { return {}; } - if (const auto leader{ connection.try_as() }) - { - return leader->PaneId(); - } + // Follower before leader: both only implement ITerminalConnection, so a + // leader try_as on a follower can falsely succeed and read garbage. if (const auto follower{ connection.try_as() }) { return follower->PaneId(); } + if (const auto leader{ connection.try_as() }) + { + return leader->PaneId(); + } return {}; } @@ -6299,6 +6323,70 @@ namespace winrt::TerminalApp::implementation return nullptr; } + TerminalConnection::ITerminalConnection TerminalPage::_HtmAnyConnectionInWindow() const + { + // Prefer the focused pane, but CLI actions (``wt -w last split-pane``) + // often land before XAML focus is on the TermControl. Fall back to any + // HTM leader/follower in this window so we never ConPTY-split an HTM pane. + if (const auto focused{ _HtmFocusedConnection() }) + { + // Follower before leader — see _HtmPaneIdFromConnection. + if (const auto follower{ focused.try_as() }) + { + if (!follower->IsClosed()) + { + return focused; + } + } + else if (focused.try_as()) + { + return focused; + } + } + for (const auto& tab : _tabs) + { + if (const auto tabImpl{ _GetTabImpl(tab) }) + { + if (const auto root{ tabImpl->GetRootPane() }) + { + TerminalConnection::ITerminalConnection found{ nullptr }; + root->WalkTree([&](const auto& pane) { + if (found) + { + return false; + } + const auto control = pane->GetTerminalControl(); + if (!control) + { + return false; + } + const auto connection = control.Connection(); + if (const auto follower{ connection.try_as() }) + { + if (!follower->IsClosed()) + { + found = connection; + return true; + } + return false; + } + if (connection.try_as()) + { + found = connection; + return true; + } + return false; + }); + if (found) + { + return found; + } + } + } + } + return nullptr; + } + std::shared_ptr TerminalPage::_HtmFindPane(const std::string& paneId) const { if (paneId.empty()) @@ -6346,32 +6434,104 @@ namespace winrt::TerminalApp::implementation } sourcePane->SetActive(); } - else + else if (const auto focused{ _GetFocusedTabImpl() }) { - tabImpl = _GetFocusedTabImpl(); + // Never split the tmux -CC gateway; if the home pane is not ready + // yet, open the new follower as its own tab instead. + if (!focused->GetActiveTerminalControl() || + !focused->GetActiveTerminalControl().Connection().try_as()) + { + tabImpl = focused; + } } - winrt::TerminalApp::Tab sourceTab{ nullptr }; - if (tabImpl) + if (!tabImpl) { - sourceTab = *tabImpl; + _HtmOpenFollowerAsTab(follower); + return; } + winrt::TerminalApp::Tab sourceTab{ *tabImpl }; auto newPane = _MakeTerminalPane(nullptr, sourceTab, follower); + if (!newPane) + { + _HtmOpenFollowerAsTab(follower); + return; + } const auto direction = vertical ? SplitDirection::Right : SplitDirection::Down; _SplitPane(tabImpl, direction, 0.5f, newPane); } + void TerminalPage::_HtmNewWindow(TerminalConnection::ITerminalConnection follower) + { + // Always a new OS window (gateway stays a control plane). Used for + // ShortcutAction::NewWindow and server-driven new-window panes. + if (!follower) + { + return; + } + winrt::TerminalApp::CommandlineArgs cmdArgs{}; + cmdArgs.Connection(std::move(follower)); + winrt::TerminalApp::WindowRequestedArgs request{ 0, cmdArgs }; + RequestNewWindow.raise(*this, request); + } + void TerminalPage::_HtmNewTab(TerminalConnection::ITerminalConnection follower) { - winrt::TerminalApp::Tab sourceTab{ nullptr }; - if (const auto focused{ _GetFocusedTabImpl() }) + if (!follower) + { + return; + } + // Adding a tab on this page (must already be a native HTM host window). + if (auto* session{ _HtmSessionForConnection(follower) }) { - sourceTab = *focused; + session->RegisterFollowerPage(this); } - auto newPane = _MakeTerminalPane(nullptr, sourceTab, follower); + auto newPane = _MakeTerminalPane(nullptr, nullptr, follower); + if (!newPane) + { + _HtmNewWindow(std::move(follower)); + return; + } + newPane->WalkTree([](const auto& pane) { + pane->FinalizeConfigurationGivenDefault(); + }); _CreateNewTabFromPane(newPane); } - void TerminalPage::_HtmClosePane(const std::string& paneId) + void TerminalPage::_HtmOpenFollowerAsTab(TerminalConnection::ITerminalConnection follower) + { + if (!follower) + { + return; + } + if (auto* session{ _HtmSessionForConnection(follower) }) + { + // If this window already hosts HTM followers, tab here directly. + if (_HtmAnyConnectionInWindow().try_as()) + { + _HtmNewTab(std::move(follower)); + return; + } + session->OpenFollowerAsTab(follower); + return; + } + _HtmNewWindow(std::move(follower)); + } + + void TerminalPage::_HtmOpenFollowerAsWindow(TerminalConnection::ITerminalConnection follower) + { + if (!follower) + { + return; + } + if (auto* session{ _HtmSessionForConnection(follower) }) + { + session->OpenFollowerAsWindow(follower); + return; + } + _HtmNewWindow(std::move(follower)); + } + + bool TerminalPage::_HtmClosePane(const std::string& paneId) { if (auto pane{ _HtmFindPane(paneId) }) { @@ -6383,6 +6543,60 @@ namespace winrt::TerminalApp::implementation } } _HandleClosePaneRequested(pane); + return true; } + return false; + } + + bool TerminalPage::_HtmSetTabTitleForPane(const std::string& paneId, const winrt::hstring& title) + { + if (paneId.empty() || title.empty()) + { + return false; + } + for (const auto& tab : _tabs) + { + if (const auto tabImpl{ _GetTabImpl(tab) }) + { + if (const auto root{ tabImpl->GetRootPane() }) + { + const auto found = root->_FindPane([&](const auto& candidate) { + const auto control = candidate->GetTerminalControl(); + if (!control) + { + return false; + } + return _HtmPaneIdFromConnection(control.Connection()) == paneId; + }); + if (found) + { + tabImpl->SetTabText(title); + return true; + } + } + } + } + return false; + } + + HtmSession* TerminalPage::_HtmSessionForConnection(const TerminalConnection::ITerminalConnection& connection) const + { + // Follower before leader — see _HtmPaneIdFromConnection. + if (connection) + { + if (const auto follower{ connection.try_as() }) + { + return follower->Session(); + } + if (const auto leader{ connection.try_as() }) + { + return leader->Session(); + } + } + if (_htmSession && _htmSession->IsActive()) + { + return _htmSession.get(); + } + return nullptr; } } diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index 991a28c1d12..66c846c8a0c 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -389,10 +389,16 @@ namespace winrt::TerminalApp::implementation void _restartPaneConnection(const TerminalApp::TerminalPaneContent&, const winrt::Windows::Foundation::IInspectable&); void _HtmSplitExisting(const std::string& sourcePaneId, winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower, bool vertical); + void _HtmNewWindow(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower); void _HtmNewTab(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower); - void _HtmClosePane(const std::string& paneId); + void _HtmOpenFollowerAsTab(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower); + void _HtmOpenFollowerAsWindow(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower); + bool _HtmClosePane(const std::string& paneId); + bool _HtmSetTabTitleForPane(const std::string& paneId, const winrt::hstring& title); + HtmSession* _HtmSessionForConnection(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection) const; std::string _HtmPaneIdFromConnection(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection) const; winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection _HtmFocusedConnection() const; + winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection _HtmAnyConnectionInWindow() const; std::shared_ptr _HtmFindPane(const std::string& paneId) const; void _OpenNewWindow(const Microsoft::Terminal::Settings::Model::INewContentArgs& contentArgs); diff --git a/src/cascadia/TerminalApp/TerminalPaneContent.cpp b/src/cascadia/TerminalApp/TerminalPaneContent.cpp index bdb72c941d9..2ab2b968647 100644 --- a/src/cascadia/TerminalApp/TerminalPaneContent.cpp +++ b/src/cascadia/TerminalApp/TerminalPaneContent.cpp @@ -6,6 +6,7 @@ #include +#include "HtmConnections.h" #include "TerminalSettingsCache.h" #include "../../types/inc/utils.hpp" @@ -227,6 +228,7 @@ namespace winrt::TerminalApp::implementation co_return; } + bool closePane = false; if (_profile) { const auto mode = _profile.CloseOnExit(); @@ -242,9 +244,24 @@ namespace winrt::TerminalApp::implementation // See GH #13325 for discussion. (mode == CloseOnExitMode::Automatic && _isDefTermSession)) { - CloseRequested.raise(nullptr, nullptr); + closePane = true; } } + // HTM followers are virtual mux panes: when ForceCloseUi / kill-pane marks + // them Closed, the TermControl must tear down even if closeOnExit is never + // (otherwise detach leaves inert native windows the e2e had to WM_CLOSE). + if (!closePane && + Feature_HtmIntegration::IsEnabled() && + newConnectionState == ConnectionState::Closed && + _control && + _control.Connection().try_as()) + { + closePane = true; + } + if (closePane) + { + CloseRequested.raise(nullptr, nullptr); + } } // Method Description: diff --git a/src/cascadia/ut_app/HtmProtocolTests.cpp b/src/cascadia/ut_app/HtmProtocolTests.cpp index 4741617eb68..4b7619b6eb2 100644 --- a/src/cascadia/ut_app/HtmProtocolTests.cpp +++ b/src/cascadia/ut_app/HtmProtocolTests.cpp @@ -72,6 +72,67 @@ namespace TerminalAppUnitTests VERIFY_ARE_EQUAL("REST", second.remainder); } + TEST_METHOD(ConPtyHtmCarrierRoundTrip) + { + const std::string payload{ TmuxControlDcs }; + const auto encoded = EncodeConPtyHtmCarrier(payload); + VERIFY_IS_TRUE(encoded.find("\x1b[?777;") == 0); + const auto decoded = DecodeConPtyHtmCarrier("", encoded); + VERIFY_ARE_EQUAL(payload, decoded.decoded); + VERIFY_IS_TRUE(decoded.pending.empty()); + } + + TEST_METHOD(ConPtyHtmCarrierSplitAcrossChunks) + { + const auto encoded = EncodeConPtyHtmCarrier("ab"); + const auto cut = encoded.size() / 2; + const auto first = DecodeConPtyHtmCarrier("", encoded.substr(0, cut)); + VERIFY_IS_TRUE(first.decoded.empty()); + VERIFY_IS_FALSE(first.pending.empty()); + const auto second = DecodeConPtyHtmCarrier(first.pending, encoded.substr(cut)); + VERIFY_ARE_EQUAL("ab", second.decoded); + VERIFY_IS_TRUE(second.pending.empty()); + } + + TEST_METHOD(Win32InputModeKeyDown) + { + const auto decoded = DecodeWin32InputMode("\x1b[65;0;97;1;0;1_"); + VERIFY_ARE_EQUAL("a", decoded); + VERIFY_IS_TRUE(DecodeWin32InputMode("\x1b[65;0;97;0;0;1_").empty()); + VERIFY_ARE_EQUAL("\r", DecodeWin32InputMode("\x1b[13;0;13;1;0;1_")); + VERIFY_ARE_EQUAL("\x1b", DecodeWin32InputMode("\x1b[27;0;0;1;0;1_")); + } + + TEST_METHOD(Win32InputModeSurrogatePairEmoji) + { + // U+1F600 😀 arrives as two KEYEVENTF_UNICODE units (D83D DE00). + Win32InputDecodeState state; + VERIFY_ARE_EQUAL("", DecodeWin32InputMode("\x1b[0;0;55357;1;0;1_", state)); + VERIFY_ARE_EQUAL(u8"\U0001F600", DecodeWin32InputMode("\x1b[0;0;56832;1;0;1_", state)); + VERIFY_ARE_EQUAL(static_cast(0), state.pendingHigh); + } + + TEST_METHOD(PaneIdsFromTmuxLayoutLeavesAndSplits) + { + const auto single = PaneIdsFromTmuxLayout("80x24,0,0,0"); + VERIFY_ARE_EQUAL(size_t{ 1 }, single.size()); + VERIFY_ARE_EQUAL("%0", single[0]); + const auto split = PaneIdsFromTmuxLayout("80x24,0,0{40x24,0,0,1,40x24,40,0,2}"); + VERIFY_ARE_EQUAL(size_t{ 2 }, split.size()); + VERIFY_ARE_EQUAL("%1", split[0]); + VERIFY_ARE_EQUAL("%2", split[1]); + } + + TEST_METHOD(TmuxCommandMenuMatchesITerm2) + { + const std::string menu{ TmuxCommandMenu }; + VERIFY_IS_TRUE(menu.find("** tmux mode started **") != std::string::npos); + VERIFY_IS_TRUE(menu.find("esc Detach cleanly.") != std::string::npos); + VERIFY_IS_TRUE(menu.find("Force-quit tmux mode.") != std::string::npos); + VERIFY_IS_TRUE(menu.find("Toggle logging.") != std::string::npos); + VERIFY_IS_TRUE(menu.find("Run tmux command.") != std::string::npos); + } + TEST_METHOD(InsertKeysFrameContainsUuidAndPayload) { const std::string pane{ "12345678-1234-1234-1234-1234567890ab" }; From 491dc17459cdbb2014195ead1cbd7408758f915f Mon Sep 17 00:00:00 2001 From: Jason Gauci Date: Wed, 16 Sep 2026 00:56:04 -0500 Subject: [PATCH 7/9] Complete Windows Terminal tmux control-mode integration Rename the Windows Terminal-side HTM integration types, protocol helpers, feature flag, project entries, and unit test to tmux terminology while retaining htm/htmd as the EternalTerminal executable and transport names. Route native tmux panes through Windows Terminal tabs, splits, and windows; preserve follower ownership across layout notifications; support close, resize, title, detach, force-quit, and control-command behavior; and keep command-line actions attached to the active mux session. Persist and restore iTerm2-compatible @affinities across reconnects. Ignore the pre-query control reply before consuming the affinity response, and use the explicitly focused source pane before asynchronous active-window state so tabs consistently join the correct native window. Add x64 and ARM64 linker target-machine settings and update project/test references for the renamed tmux sources. Verified with the complete Windows Terminal + htm E2E suite: layout, stress, corners, affinities, control-plane, race-heavy writer/close cases, detach/reattach, and clean htmd socket shutdown. --- .../TerminalApp/AppActionHandlers.cpp | 80 +- src/cascadia/TerminalApp/HtmSession.cpp | 861 ------------ src/cascadia/TerminalApp/TabManagement.cpp | 41 +- .../TerminalApp/TerminalAppLib.vcxproj | 10 +- .../TerminalAppLib.vcxproj.filters | 10 +- src/cascadia/TerminalApp/TerminalPage.cpp | 147 +- src/cascadia/TerminalApp/TerminalPage.h | 30 +- .../TerminalApp/TerminalPaneContent.cpp | 8 +- ...HtmConnections.cpp => TmuxConnections.cpp} | 94 +- .../{HtmConnections.h => TmuxConnections.h} | 74 +- .../{HtmProtocol.h => TmuxProtocol.h} | 23 +- src/cascadia/TerminalApp/TmuxSession.cpp | 1229 +++++++++++++++++ .../{HtmSession.h => TmuxSession.h} | 57 +- .../WindowsTerminal/WindowEmperor.cpp | 21 +- .../ut_app/TerminalApp.UnitTests.vcxproj | 2 +- ...rotocolTests.cpp => TmuxProtocolTests.cpp} | 98 +- src/common.build.pre.props | 10 + src/features.xml | 4 +- 18 files changed, 1638 insertions(+), 1161 deletions(-) delete mode 100644 src/cascadia/TerminalApp/HtmSession.cpp rename src/cascadia/TerminalApp/{HtmConnections.cpp => TmuxConnections.cpp} (82%) rename src/cascadia/TerminalApp/{HtmConnections.h => TmuxConnections.h} (67%) rename src/cascadia/TerminalApp/{HtmProtocol.h => TmuxProtocol.h} (96%) create mode 100644 src/cascadia/TerminalApp/TmuxSession.cpp rename src/cascadia/TerminalApp/{HtmSession.h => TmuxSession.h} (52%) rename src/cascadia/ut_app/{HtmProtocolTests.cpp => TmuxProtocolTests.cpp} (90%) diff --git a/src/cascadia/TerminalApp/AppActionHandlers.cpp b/src/cascadia/TerminalApp/AppActionHandlers.cpp index 834a451f299..65fbdf0160b 100644 --- a/src/cascadia/TerminalApp/AppActionHandlers.cpp +++ b/src/cascadia/TerminalApp/AppActionHandlers.cpp @@ -5,7 +5,7 @@ #include "App.h" #include "TerminalPage.h" -#include "HtmConnections.h" +#include "TmuxConnections.h" #include "ScratchpadContent.h" #include "../WinRTUtils/inc/WtExeUtils.h" #include "../../types/inc/utils.hpp" @@ -65,13 +65,23 @@ namespace winrt::TerminalApp::implementation void TerminalPage::_HandleDuplicateTab(const IInspectable& /*sender*/, const ActionEventArgs& args) { - if (Feature_HtmIntegration::IsEnabled()) - { - if (auto* session{ _HtmSessionForConnection(_HtmFocusedConnection()) }) - { - if (const auto follower{ session->CreateFollowerForUserTab() }) + if (Feature_TmuxIntegration::IsEnabled()) + { + auto focusedConnection{ _TmuxFocusedConnection() }; + auto* session = _TmuxSessionForConnection(focusedConnection); + // A new TermControl from a split can briefly own XAML focus while + // its TMUX connection is still being established. Keep duplicate + // tab in the same mux by selecting an existing window connection. + if (!session) + { + focusedConnection = _TmuxAnyConnectionInWindow(); + session = _TmuxSessionForConnection(focusedConnection); + } + if (session) + { + if (const auto follower{ session->CreateFollowerForUserTab(_TmuxPaneIdFromConnection(focusedConnection)) }) { - _HtmOpenFollowerAsTab(follower); + _TmuxOpenFollowerAsTab(follower); args.Handled(true); return; } @@ -109,11 +119,11 @@ namespace winrt::TerminalApp::implementation void TerminalPage::_HandleClosePane(const IInspectable& /*sender*/, const ActionEventArgs& args) { - if (Feature_HtmIntegration::IsEnabled()) + if (Feature_TmuxIntegration::IsEnabled()) { - if (const auto conn{ _HtmFocusedConnection() }) + if (const auto conn{ _TmuxFocusedConnection() }) { - if (auto* session{ _HtmSessionForConnection(conn) }) + if (auto* session{ _TmuxSessionForConnection(conn) }) { session->HandleUserClose(conn); } @@ -288,10 +298,10 @@ namespace winrt::TerminalApp::implementation return false; } - void TerminalPage::_HandleSplitPane(const IInspectable& sender, - const ActionEventArgs& args) - { - if (args == nullptr) + void TerminalPage::_HandleSplitPane(const IInspectable& sender, + const ActionEventArgs& args) + { + if (args == nullptr) { args.Handled(false); } @@ -302,20 +312,20 @@ namespace winrt::TerminalApp::implementation const auto& activeTab{ _senderOrFocusedTab(sender) }; // Intercept before the invalid-profile bail-out so a command-line - // duplicate split on an HTM follower still talks to htmd. - // Prefer any HTM connection in this window: CLI ``-w last`` often + // duplicate split on an TMUX follower still talks to htmd. + // Prefer any TMUX connection in this window: CLI ``-w last`` often // arrives before the TermControl is the XAML focus target. - if (Feature_HtmIntegration::IsEnabled()) + if (Feature_TmuxIntegration::IsEnabled()) { - const auto htmConn{ _HtmAnyConnectionInWindow() }; - auto* session = _HtmSessionForConnection(htmConn); - if (!session && _htmSession && _htmSession->IsActive()) + const auto tmuxConn{ _TmuxAnyConnectionInWindow() }; + auto* session = _TmuxSessionForConnection(tmuxConn); + if (!session && _tmuxSession && _tmuxSession->IsActive()) { - session = _htmSession.get(); + session = _tmuxSession.get(); } - if (session) + if (session) { - auto sourceId = _HtmPaneIdFromConnection(htmConn); + auto sourceId = _TmuxPaneIdFromConnection(tmuxConn); if (sourceId.empty() || !session->HasFollower(sourceId)) { sourceId = session->LeaderPaneId(); @@ -332,12 +342,12 @@ namespace winrt::TerminalApp::implementation } const auto direction = realArgs.SplitDirection(); const bool vertical = direction != SplitDirection::Up && direction != SplitDirection::Down; - if (const auto follower{ session->CreateFollowerForUserSplit(sourceId, vertical) }) - { + if (const auto follower{ session->CreateFollowerForUserSplit(sourceId, vertical) }) + { // Prefer splitting the focused follower tab; otherwise // locate the source pane across windows. - if (htmConn && htmConn.try_as() && session->HasFollower(sourceId) && - _HtmPaneIdFromConnection(htmConn) == sourceId) + if (tmuxConn && AsTmuxFollower(tmuxConn) && session->HasFollower(sourceId) && + _TmuxPaneIdFromConnection(tmuxConn) == sourceId) { _SplitPane(activeTab, direction, @@ -346,7 +356,7 @@ namespace winrt::TerminalApp::implementation } else { - _HtmSplitExisting(sourceId, follower, vertical); + _TmuxSplitExisting(sourceId, follower, vertical); } args.Handled(true); return; @@ -988,22 +998,22 @@ namespace winrt::TerminalApp::implementation void TerminalPage::_HandleNewWindow(const IInspectable& /*sender*/, const ActionEventArgs& actionArgs) { - if (Feature_HtmIntegration::IsEnabled()) + if (Feature_TmuxIntegration::IsEnabled()) { - if (auto* session{ _HtmSessionForConnection(_HtmFocusedConnection()) }) + if (auto* session{ _TmuxSessionForConnection(_TmuxFocusedConnection()) }) { - if (const auto follower{ session->CreateFollowerForUserTab() }) + if (const auto follower{ session->CreateFollowerForUserWindow() }) { - _HtmOpenFollowerAsWindow(follower); + _TmuxOpenFollowerAsWindow(follower); actionArgs.Handled(true); return; } } - else if (_htmSession && _htmSession->IsActive()) + else if (_tmuxSession && _tmuxSession->IsActive()) { - if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) + if (const auto follower{ _tmuxSession->CreateFollowerForUserWindow() }) { - _HtmOpenFollowerAsWindow(follower); + _TmuxOpenFollowerAsWindow(follower); actionArgs.Handled(true); return; } diff --git a/src/cascadia/TerminalApp/HtmSession.cpp b/src/cascadia/TerminalApp/HtmSession.cpp deleted file mode 100644 index b453a173ec4..00000000000 --- a/src/cascadia/TerminalApp/HtmSession.cpp +++ /dev/null @@ -1,861 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -#include "pch.h" -#include "HtmSession.h" -#include "HtmConnections.h" -#include "TerminalPage.h" - -#include - -using namespace winrt::Microsoft::Terminal::TerminalConnection; -using namespace ::Microsoft::Terminal::Htm; - -namespace winrt::TerminalApp::implementation -{ - void HtmSession::SetNativeHostPage(TerminalPage* page) noexcept - { - std::lock_guard lock{ _mutex }; - if (!_nativeHostPage && page) - { - _nativeHostPage = page->get_weak(); - } - } - - TerminalPage* HtmSession::NativeHostPage() const noexcept - { - std::lock_guard lock{ _mutex }; - if (const auto host = _nativeHostPage.get()) - { - return host.get(); - } - return nullptr; - } - - void HtmSession::ClearNativeHostPage(TerminalPage* page) noexcept - { - std::lock_guard lock{ _mutex }; - if (const auto host = _nativeHostPage.get(); host && host.get() == page) - { - _nativeHostPage = nullptr; - } - std::erase_if(_followerPages, [page](const winrt::weak_ref& weak) { - const auto live = weak.get(); - return !live || live.get() == page; - }); - } - - void HtmSession::RegisterFollowerPage(TerminalPage* page) noexcept - { - if (!page) - { - return; - } - std::lock_guard lock{ _mutex }; - for (const auto& weak : _followerPages) - { - if (const auto live = weak.get(); live && live.get() == page) - { - return; - } - } - _followerPages.push_back(page->get_weak()); - if (!_nativeHostPage) - { - _nativeHostPage = page->get_weak(); - } - } - - void HtmSession::OpenFollowerAsTab(const ITerminalConnection& follower) - { - if (!follower) - { - return; - } - winrt::com_ptr host; - { - std::lock_guard lock{ _mutex }; - host = _nativeHostPage.get(); - } - if (host) - { - host->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [host, follower]() { - host->_HtmNewTab(follower); - }); - return; - } - // No native host yet — first tab still needs an OS window to live in. - if (_page) - { - _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, follower]() { - _page->_HtmNewWindow(follower); - }); - } - } - - void HtmSession::OpenFollowerAsWindow(const ITerminalConnection& follower) - { - if (!follower || !_page) - { - return; - } - _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, follower]() { - _page->_HtmNewWindow(follower); - }); - } - - HtmSession::HtmSession(TerminalPage* page) : _page{ page } {} - - void HtmSession::AttachLeader(HtmLeaderConnection* leader) - { - { - std::lock_guard lock{ _mutex }; - _leader = leader; - _detaching = false; - } - // DCS is detected inside ConptyConnection's output callback. Queue the - // first command so that callback can return before we call WriteInput - // on the same connection. - const auto weakLeader = leader->get_weak(); - _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, weakLeader]() { - if (const auto leader = weakLeader.get(); leader && _leader == leader.get() && - leader->State() == ConnectionState::Connected) - { - leader->InjectOutput(std::string{ TmuxCommandMenu }); - WriteToLeader("refresh-client -C 80x24"); - } - }); - } - - void HtmSession::DetachLeader(HtmLeaderConnection* leader) - { - if (_leader == leader) - { - _leader = nullptr; - // Snapshot/close native follower windows before clearing maps so we - // do not leave TermControls writing through a torn-down session. - _closeFollowerUi(); - _exitHtmMode(); - } - } - - bool HtmSession::IsActive() const noexcept - { - std::lock_guard lock{ _mutex }; - return _leader != nullptr; - } - - bool HtmSession::IsHtmConnection(const ITerminalConnection& connection) const - { - // Follower before leader: both only implement ITerminalConnection, so a - // leader try_as on a follower can falsely succeed. - if (const auto follower{ connection.try_as() }) - return _followers.contains(follower->PaneId()); - if (const auto leader{ connection.try_as() }) - return leader.get() == _leader; - return false; - } - - std::string HtmSession::LeaderPaneId() const - { - std::lock_guard lock{ _mutex }; - auto live = [&](const std::string& id) -> bool { - const auto it = _followers.find(id); - return it != _followers.end() && it->second && !it->second->IsClosed(); - }; - if (!_homePaneId.empty() && live(_homePaneId)) - { - return _homePaneId; - } - for (const auto& [id, follower] : _followers) - { - if (follower && !follower->IsClosed()) - { - return id; - } - } - if (_leader) - { - auto id = _leader->PaneId(); - if (!id.empty()) - { - return id; - } - } - return "%0"; - } - - void HtmSession::RegisterFollower(HtmFollowerConnection* follower) - { - if (follower && !follower->PaneId().empty()) - { - std::lock_guard lock{ _mutex }; - _followers[follower->PaneId()] = follower; - } - } - - void HtmSession::UnregisterFollower(HtmFollowerConnection* follower) - { - if (follower) - { - std::lock_guard lock{ _mutex }; - _followers.erase(follower->PaneId()); - } - } - - bool HtmSession::HasFollower(const std::string& paneId) const - { - std::lock_guard lock{ _mutex }; - const auto it = _followers.find(paneId); - return it != _followers.end() && it->second && !it->second->IsClosed(); - } - - std::string HtmSession::FirstLiveFollowerPaneId() const - { - std::lock_guard lock{ _mutex }; - for (const auto& [id, follower] : _followers) - { - if (follower && !follower->IsClosed()) - { - return id; - } - } - return {}; - } - - void HtmSession::WriteToLeader(std::string_view command) - { - if (!_leader) - { - return; - } - std::string line{ command }; - // A Windows console in line-input mode submits on CR, not LF. - // htmd accepts CR, LF, and CRLF as tmux command delimiters. - if (line.empty() || (line.back() != '\r' && line.back() != '\n')) - line.push_back('\r'); - _logProtocol(">", command); - // Never WriteInput the leader ConPTY on the UI thread or nested inside - // the leader's TerminalOutput handler. Action handlers (split/new-tab) - // and follower Start/Resize otherwise deadlock the window ("Not - // Responding") before htmd ever sees split-window. - // Hold a strong ref: killing htmd can destroy the leader while a queued - // write still runs (Debug abort / UAF during stress teardown). - const auto strongLeader = _leader->get_strong(); - winrt::Windows::System::Threading::ThreadPool::RunAsync( - [strongLeader, line = std::move(line)](const auto&) { - if (strongLeader) - { - strongLeader->WriteRaw(line); - } - }); - } - - void HtmSession::SendKeys(std::string_view paneId, std::string_view utf8) - { - if (paneId.empty()) - return; - static constexpr char hex[] = "0123456789abcdef"; - std::string command{ "send-keys -H -t " }; - command += paneId; - for (unsigned char byte : utf8) - { - command += " 0x"; - command += hex[byte >> 4]; - command += hex[byte & 15]; - } - WriteToLeader(command); - } - - void HtmSession::HandleLine(std::string_view line) - { - _logProtocol("<", line); - if (line.rfind("%output ", 0) == 0) - { - if (_detaching) - { - return; - } - const auto first = line.find(' ', 8); - if (first != std::string_view::npos) - _appendToPane(std::string{ line.substr(8, first - 8) }, UnescapeControlOutput(line.substr(first + 1))); - return; - } - if (line.rfind("%window-pane-changed ", 0) == 0) - { - // "%window-pane-changed @0 %1" - const auto body = line.substr(21); - const auto space = body.find(' '); - std::string windowId; - std::string paneId; - if (space == std::string_view::npos) - { - paneId = std::string{ body }; - } - else - { - windowId = std::string{ body.substr(0, space) }; - paneId = std::string{ body.substr(space + 1) }; - } - if (paneId.empty()) - { - return; - } - HtmFollowerConnection* follower = nullptr; - { - std::lock_guard lock{ _mutex }; - if (!windowId.empty()) - { - _paneToWindow[paneId] = windowId; - } - if (!_pendingFollowers.empty()) - { - follower = _pendingFollowers.front().connection; - _pendingFollowers.erase(_pendingFollowers.begin()); - _followers[paneId] = follower; - } - } - if (follower) - { - follower->SetPaneId(paneId); - } - else - { - _ensureNativePane(paneId); - } - return; - } - if (line.rfind("%window-renamed ", 0) == 0) - { - // "%window-renamed @0 timeout" - const auto body = line.substr(16); - const auto space = body.find(' '); - if (space != std::string_view::npos && space + 1 < body.size()) - { - const std::string windowId{ body.substr(0, space) }; - const std::string name{ body.substr(space + 1) }; - _renameWindowTabs(windowId, name); - } - return; - } - if (line.rfind("%layout-change ", 0) == 0) - { - // tmux does not send %window-pane-changed for a newly-created - // window. Its initial layout is necessarily a single leaf, whose - // final comma-separated field is the pane ID. Treat that - // authoritative notification as a fallback when the new-window - // command reply races follower startup or delivery. - const auto layoutBegin = line.find(' ', 15); - const auto layoutEnd = layoutBegin == std::string_view::npos ? std::string_view::npos : line.find(' ', layoutBegin + 1); - if (layoutBegin != std::string_view::npos && layoutEnd != std::string_view::npos) - { - const auto layout = line.substr(layoutBegin + 1, layoutEnd - layoutBegin - 1); - _syncFollowersToLayout(layout); - const auto comma = layout.rfind(','); - if (comma != std::string_view::npos && - layout.find_first_of("[]{}") == std::string_view::npos) - { - const std::string paneId{ "%" + std::string{ layout.substr(comma + 1) } }; - HtmFollowerConnection* follower = nullptr; - bool splitInFlight = false; - { - std::lock_guard lock{ _mutex }; - if (!_pendingFollowers.empty() && _pendingFollowers.front().isTab) - { - follower = _pendingFollowers.front().connection; - _pendingFollowers.erase(_pendingFollowers.begin()); - _followers[paneId] = follower; - } - else if (!_pendingFollowers.empty()) - { - // A user split is in flight; wait for %window-pane-changed - // or the -P reply rather than opening a duplicate tab. - splitInFlight = true; - } - } - if (follower) - { - follower->SetPaneId(paneId); - } - else if (!splitInFlight) - { - _ensureNativePane(paneId); - } - } - } - return; - } - if (line.rfind("%begin ", 0) == 0) - { - _inReply = true; - _replyLines.clear(); - return; - } - if (line.rfind("%end ", 0) == 0 || line.rfind("%error ", 0) == 0) - { - _finishReply(); - return; - } - if (line == "%exit") - { - _closeFollowerUi(); - _exitHtmMode(); - return; - } - if (_inReply) - _replyLines.emplace_back(line); - } - - void HtmSession::_finishReply() - { - _inReply = false; - if (_replyLines.empty()) - return; - // -P -F '#{pane_id}' replies with exactly the new %pane identifier. - const auto id = _replyLines.front(); - if (id.empty() || id.front() != '%') - return; - HtmFollowerConnection* follower = nullptr; - { - std::lock_guard lock{ _mutex }; - if (_pendingFollowers.empty()) - return; - follower = _pendingFollowers.front().connection; - _pendingFollowers.erase(_pendingFollowers.begin()); - } - follower->SetPaneId(id); - std::lock_guard lock{ _mutex }; - _followers[id] = follower; - } - - void HtmSession::HandleExitSequence() - { - _detaching = true; - _closeFollowerUi(); - _exitHtmMode(); - _detaching = false; - } - - ITerminalConnection HtmSession::CreateFollowerForUserSplit(const std::string& sourcePaneId, bool vertical) - { - if (!_leader || sourcePaneId.empty()) - return nullptr; - auto follower = winrt::make_self(this, ""); - { - std::lock_guard lock{ _mutex }; - _pendingFollowers.push_back({ follower.get(), false }); - } - WriteToLeader(std::string{ "split-window -P -F '#{pane_id}' -t " } + sourcePaneId + (vertical ? " -h" : " -v")); - return follower.as(); - } - - ITerminalConnection HtmSession::CreateFollowerForUserTab() - { - if (!_leader) - return nullptr; - auto follower = winrt::make_self(this, ""); - { - std::lock_guard lock{ _mutex }; - _pendingFollowers.push_back({ follower.get(), true }); - } - WriteToLeader("new-window -P -F '#{pane_id}'"); - return follower.as(); - } - - bool HtmSession::HandleUserClose(const ITerminalConnection& connection) - { - if (_suppressClosePackets || !IsHtmConnection(connection)) - return false; - if (const auto follower{ connection.try_as() }) - { - WriteToLeader("kill-pane -t " + follower->PaneId()); - return true; - } - if (const auto leader{ connection.try_as() }) - { - WriteToLeader("kill-pane -t " + leader->PaneId()); - return true; - } - return false; - } - - void HtmSession::_appendToPane(const std::string& paneId, std::string_view utf8) - { - const std::string data{ utf8 }; - _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, paneId, data]() { - HtmFollowerConnection* follower = nullptr; - { - std::lock_guard lock{ _mutex }; - if (const auto it = _followers.find(paneId); it != _followers.end()) - { - follower = it->second; - } - } - if (follower) - { - follower->InjectOutput(data); - } - }); - } - - void HtmSession::_exitHtmMode() - { - _suppressClosePackets = true; - _commandPrompt = false; - _commandBuffer.clear(); - _homePaneId.clear(); - std::lock_guard lock{ _mutex }; - _followers.clear(); - _pendingFollowers.clear(); - _pendingNativePanes.clear(); - _suppressClosePackets = false; - } - - void HtmSession::_gatewayPrint(std::string_view text) - { - if (_leader) - { - _leader->InjectOutput(text); - } - } - - void HtmSession::_logProtocol(std::string_view direction, std::string_view line) - { - if (!_protocolLogging) - { - return; - } - if (line.rfind("%output ", 0) == 0) - { - return; - } - std::string text{ "\r\n" }; - text.append(direction); - text.push_back(' '); - auto visible = line; - if (!visible.empty() && (visible.back() == '\r' || visible.back() == '\n')) - { - visible.remove_suffix(1); - } - text.append(visible); - text.append("\r\n"); - _gatewayPrint(text); - } - - void HtmSession::_ensureNativePane(const std::string& paneId) - { - if (paneId.empty() || !_page) - { - return; - } - { - std::lock_guard lock{ _mutex }; - if (_followers.contains(paneId) || _pendingNativePanes.contains(paneId)) - { - return; - } - _pendingNativePanes.insert(paneId); - } - _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, paneId]() { - auto releasePending = wil::scope_exit([&]() { - std::lock_guard lock{ _mutex }; - _pendingNativePanes.erase(paneId); - }); - { - std::lock_guard lock{ _mutex }; - if (!_leader || _followers.contains(paneId)) - { - return; - } - } - auto follower = winrt::make_self(this, paneId); - { - std::lock_guard lock{ _mutex }; - _followers[paneId] = follower.get(); - if (_homePaneId.empty()) - { - _homePaneId = paneId; - } - } - OpenFollowerAsWindow(follower.as()); - }); - } - - void HtmSession::_closeFollowerUi() - { - std::vector followers; - std::vector ids; - std::vector> pages; - { - std::lock_guard lock{ _mutex }; - followers.reserve(_followers.size()); - ids.reserve(_followers.size()); - for (const auto& [id, follower] : _followers) - { - ids.push_back(id); - followers.push_back(follower); - } - // Drop map entries first so late %output cannot re-enter InjectOutput - // after we mark followers closed. - _followers.clear(); - for (const auto& weak : _followerPages) - { - if (auto live = weak.get()) - { - pages.push_back(live); - } - } - _nativeHostPage = nullptr; - _followerPages.clear(); - _paneToWindow.clear(); - } - // Native HTM panes live in other OS windows (RequestNewWindow). Silence - // first, then close on each hosting page (gateway cannot _HtmFindPane them). - for (auto* follower : followers) - { - if (follower) - { - follower->ForceCloseUi(); - } - } - for (const auto& page : pages) - { - page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [page, ids]() { - for (const auto& id : ids) - { - page->_HtmClosePane(id); - } - }); - } - } - - void HtmSession::_syncFollowersToLayout(std::string_view layout) - { - if (_detaching) - { - return; - } - // Drop the optional checksum prefix ("abcd,"). - auto body = layout; - if (body.size() > 5 && body[4] == ',') - { - bool hex = true; - for (size_t i = 0; i < 4; ++i) - { - const char c = body[i]; - if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) - { - hex = false; - break; - } - } - if (hex) - { - body.remove_prefix(5); - } - } - const auto live = PaneIdsFromTmuxLayout(body); - // A non-empty layout that yields no ids is a parse miss — do not cull - // every follower (that would drop the last pane and block later splits). - if (live.empty()) - { - return; - } - std::unordered_set liveSet(live.begin(), live.end()); - std::vector stale; - std::vector staleFollowers; - { - std::lock_guard lock{ _mutex }; - for (const auto& [id, follower] : _followers) - { - if (!liveSet.contains(id)) - { - stale.push_back(id); - if (follower) - { - staleFollowers.push_back(follower); - } - } - } - for (const auto& id : stale) - { - _followers.erase(id); - _paneToWindow.erase(id); - if (_homePaneId == id) - { - _homePaneId = live.front(); - } - } - } - // ForceCloseUi + hosting-page _HtmClosePane tears down TermControls. - // Leaving silenced inert leaves forced the e2e Cmd+W workaround. - for (auto* follower : staleFollowers) - { - follower->ForceCloseUi(); - } - std::vector> pages; - { - std::lock_guard lock{ _mutex }; - for (const auto& weak : _followerPages) - { - if (auto live = weak.get()) - { - pages.push_back(live); - } - } - } - for (const auto& page : pages) - { - page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [page, stale]() { - for (const auto& id : stale) - { - page->_HtmClosePane(id); - } - }); - } - } - - void HtmSession::_renameWindowTabs(const std::string& windowId, const std::string& name) - { - if (windowId.empty() || name.empty()) - { - return; - } - std::vector paneIds; - std::vector> pages; - { - std::lock_guard lock{ _mutex }; - for (const auto& [paneId, wid] : _paneToWindow) - { - if (wid == windowId) - { - paneIds.push_back(paneId); - } - } - for (const auto& weak : _followerPages) - { - if (auto live = weak.get()) - { - pages.push_back(live); - } - } - } - if (paneIds.empty()) - { - return; - } - const auto title = winrt::hstring{ til::u8u16(name) }; - for (const auto& page : pages) - { - page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [page, paneIds, title]() { - for (const auto& paneId : paneIds) - { - page->_HtmSetTabTitleForPane(paneId, title); - } - }); - } - } - - void HtmSession::_detachCleanly() - { - _detaching = true; - WriteToLeader("detach-client"); - _closeFollowerUi(); - // Drop the leader so a leftover gateway window cannot keep sending - // split-window / send-keys into a dead ConPTY after detach-client. - _leader = nullptr; - _exitHtmMode(); - } - - void HtmSession::_forceQuit() - { - _detaching = true; - _closeFollowerUi(); - if (_leader) - { - auto* leader = _leader; - _leader = nullptr; - leader->ForceCloseClient(); - } - _exitHtmMode(); - _detaching = false; - } - - void HtmSession::_toggleLogging() - { - _protocolLogging = !_protocolLogging; - _gatewayPrint(_protocolLogging ? "\r\ntmux logging enabled\r\n" : "\r\ntmux logging disabled\r\n"); - } - - void HtmSession::_beginCommandPrompt() - { - _commandPrompt = true; - _commandBuffer.clear(); - _gatewayPrint("\r\nEnter a tmux command: "); - } - - void HtmSession::_handleCommandPromptKey(char ch) - { - if (ch == '\r' || ch == '\n') - { - _commandPrompt = false; - _gatewayPrint("\r\n"); - const auto command = std::move(_commandBuffer); - _commandBuffer.clear(); - if (!command.empty()) - { - WriteToLeader(command); - } - return; - } - if (ch == '\x7f' || ch == '\b') - { - if (!_commandBuffer.empty()) - { - _commandBuffer.pop_back(); - _gatewayPrint("\b \b"); - } - return; - } - if (ch >= 32 && ch < 127) - { - _commandBuffer.push_back(ch); - _gatewayPrint(std::string(1, ch)); - } - } - - void HtmSession::HandleLeaderInput(std::string_view keys) - { - for (unsigned char ch : keys) - { - if (_commandPrompt) - { - if (ch == 0x1b) - { - _commandPrompt = false; - _commandBuffer.clear(); - _gatewayPrint("\r\n"); - continue; - } - _handleCommandPromptKey(static_cast(ch)); - continue; - } - if (ch == 0x1b) - { - _detachCleanly(); - } - else if (ch == 'x' || ch == 'X') - { - _forceQuit(); - } - else if (ch == 'l' || ch == 'L') - { - _toggleLogging(); - } - else if (ch == 'c' || ch == 'C') - { - _beginCommandPrompt(); - } - } - } -} diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index e63de9d8dad..653aefda075 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -88,23 +88,34 @@ namespace winrt::TerminalApp::implementation // This call to _MakePane won't return nullptr, we already checked that // case above with the _maybeElevate call. - if (Feature_HtmIntegration::IsEnabled()) - { - // new-window is session-scoped. Prefer the focused HTM connection's - // session so a native HTM window can spawn another OS window. - if (auto* session{ _HtmSessionForConnection(_HtmFocusedConnection()) }) - { - if (const auto follower{ session->CreateFollowerForUserTab() }) + if (Feature_TmuxIntegration::IsEnabled()) + { + // new-window is session-scoped. Prefer the focused TMUX connection's + // session so a native TMUX window can spawn another OS window. + auto focusedConnection{ _TmuxFocusedConnection() }; + auto* session = _TmuxSessionForConnection(focusedConnection); + // Action dispatch can run while a split's new TermControl is + // becoming focused. Use any live TMUX follower in this native + // window, just as SplitPane does, rather than falling through to + // a non-TMUX ConPTY tab. + if (!session) + { + focusedConnection = _TmuxAnyConnectionInWindow(); + session = _TmuxSessionForConnection(focusedConnection); + } + if (session) + { + if (const auto follower{ session->CreateFollowerForUserTab(_TmuxPaneIdFromConnection(focusedConnection)) }) { - _HtmOpenFollowerAsTab(follower); + _TmuxOpenFollowerAsTab(follower); return S_OK; } } - else if (_htmSession && _htmSession->IsActive()) + else if (_tmuxSession && _tmuxSession->IsActive()) { - if (const auto follower{ _htmSession->CreateFollowerForUserTab() }) + if (const auto follower{ _tmuxSession->CreateFollowerForUserTab() }) { - _HtmOpenFollowerAsTab(follower); + _TmuxOpenFollowerAsTab(follower); return S_OK; } } @@ -560,15 +571,15 @@ namespace winrt::TerminalApp::implementation // To close the window here, we need to close the hosting window. if (_tabs.Size() == 0) { - if (Feature_HtmIntegration::IsEnabled()) + if (Feature_TmuxIntegration::IsEnabled()) { - if (auto* session{ _HtmSessionForConnection(_HtmAnyConnectionInWindow()) }) + if (auto* session{ _TmuxSessionForConnection(_TmuxAnyConnectionInWindow()) }) { session->ClearNativeHostPage(this); } - else if (_htmSession) + else if (_tmuxSession) { - _htmSession->ClearNativeHostPage(this); + _tmuxSession->ClearNativeHostPage(this); } } // If we are supposed to save state, make sure we clear it out diff --git a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj index 64b885de1be..54402fa869c 100644 --- a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj +++ b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj @@ -140,9 +140,9 @@ ShortcutActionDispatch.idl - - - + + + AppKeyBindings.idl @@ -250,8 +250,8 @@ - - + + Create diff --git a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters index ff469b5f947..d169f8fb5d3 100644 --- a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters +++ b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters @@ -20,8 +20,8 @@ - - + + commandPalette @@ -47,9 +47,9 @@ - - - + + + commandPalette diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index c4c5441cb20..28ecbbda677 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -16,8 +16,8 @@ #include "../TerminalSettingsAppAdapterLib/TerminalSettings.h" #include "App.h" #include "DebugTapConnection.h" -#include "HtmConnections.h" -#include "HtmSession.h" +#include "TmuxConnections.h" +#include "TmuxSession.h" #include "MarkdownPaneContent.h" #include "Remoting.h" #include "ScratchpadContent.h" @@ -229,9 +229,9 @@ namespace winrt::TerminalApp::implementation { InitializeComponent(); _WindowProperties.PropertyChanged({ get_weak(), &TerminalPage::_windowPropertyChanged }); - if (Feature_HtmIntegration::IsEnabled()) + if (Feature_TmuxIntegration::IsEnabled()) { - _htmSession = std::make_unique(this); + _tmuxSession = std::make_unique(this); } } @@ -821,10 +821,10 @@ namespace winrt::TerminalApp::implementation pane->FinalizeConfigurationGivenDefault(); }); _CreateNewTabFromPane(newPane); - // First HTM follower window becomes the tab host for later new-windows. + // First TMUX follower window becomes the tab host for later new-windows. if (const auto control{ newPane->GetTerminalControl() }) { - if (const auto follower{ control.Connection().try_as() }) + if (const auto follower{ AsTmuxFollower(control.Connection()) }) { if (auto* session{ follower->Session() }) { @@ -1610,7 +1610,7 @@ namespace winrt::TerminalApp::implementation auto settingsInternal{ winrt::get_self(settings) }; auto environment = settingsInternal->EnvironmentVariables(); Windows::Foundation::Collections::IMapView environmentView = environment; - if (Feature_HtmIntegration::IsEnabled() && environment && environment.HasKey(L"HTM_BIN_DIR")) + if (Feature_TmuxIntegration::IsEnabled() && environment && environment.HasKey(L"HTM_BIN_DIR")) { auto envMap = winrt::single_threaded_map(); for (const auto& [k, v] : environment) @@ -1683,7 +1683,7 @@ namespace winrt::TerminalApp::implementation connection.Initialize(valueSet); - if (Feature_HtmIntegration::IsEnabled() && _htmSession && + if (Feature_TmuxIntegration::IsEnabled() && _tmuxSession && connection.try_as()) { std::wstring cmd{ settings.Commandline() }; @@ -1694,7 +1694,7 @@ namespace winrt::TerminalApp::implementation if (cmd.find(L"htm.exe") != std::wstring::npos || cmd == L"htm" || cmd.ends_with(L"\\htm") || cmd.ends_with(L"/htm")) { - connection = winrt::make(connection, _htmSession.get()); + connection = winrt::make(connection, _tmuxSession.get()); } } @@ -2952,7 +2952,7 @@ namespace winrt::TerminalApp::implementation // Instead, let's just promote this first split to be a tab instead. // Crash avoided, and we don't need to worry about inserting a new-tab // command in at the start. - if (!tab) + if (!activeTab) { if (_tabs.Size() == 0) { @@ -2962,9 +2962,19 @@ namespace winrt::TerminalApp::implementation else { activeTab = _GetFocusedTabImpl(); + if (!activeTab && _tabs.Size() > 0) + { + activeTab = _GetTabImpl(_tabs.GetAt(0)); + } } } + if (!activeTab) + { + _CreateNewTabFromPane(newPane); + return; + } + // For now, prevent splitting the _settingsTab. We can always revisit this later. if (*activeTab == _settingsTab) { @@ -3663,7 +3673,7 @@ namespace winrt::TerminalApp::implementation // else: // - Reset conpty to its original size back if (!WindowProperties().IsQuakeWindow() && !Fullscreen() && - NumberOfTabs() == 1 && _GetFocusedTabImpl()->GetLeafPaneCount() == 1) + NumberOfTabs() == 1 && _GetFocusedTabImpl() && _GetFocusedTabImpl()->GetLeafPaneCount() == 1) { WindowSizeChanged.raise(*this, args); } @@ -3863,7 +3873,7 @@ namespace winrt::TerminalApp::implementation // TODO GH#5047 If we cache the NewTerminalArgs, we no longer need to do this. profile = GetClosestProfileForDuplicationOfProfile(profile); controlSettings = Settings::TerminalSettings::CreateWithProfile(_settings, _currentWindowSettings(), profile); - // HTM follower panes already have a live connection; querying + // TMUX follower panes already have a live connection; querying // WorkingDirectory can block the UI while the gateway ConPTY // is busy and is unused for virtual followers anyway. if (!existingConnection) @@ -4722,7 +4732,10 @@ namespace winrt::TerminalApp::implementation winrt::com_ptr TerminalPage::_GetTabImpl(const TerminalApp::Tab& tab) { winrt::com_ptr tabImpl; - tabImpl.copy_from(winrt::get_self(tab)); + if (tab) + { + tabImpl.copy_from(winrt::get_self(tab)); + } return tabImpl; } @@ -6292,7 +6305,7 @@ namespace winrt::TerminalApp::implementation return profileMenuItemFlyout; } - std::string TerminalPage::_HtmPaneIdFromConnection(const TerminalConnection::ITerminalConnection& connection) const + std::string TerminalPage::_TmuxPaneIdFromConnection(const TerminalConnection::ITerminalConnection& connection) const { if (!connection) { @@ -6300,18 +6313,18 @@ namespace winrt::TerminalApp::implementation } // Follower before leader: both only implement ITerminalConnection, so a // leader try_as on a follower can falsely succeed and read garbage. - if (const auto follower{ connection.try_as() }) + if (const auto follower{ AsTmuxFollower(connection) }) { return follower->PaneId(); } - if (const auto leader{ connection.try_as() }) + if (const auto leader{ AsTmuxLeader(connection) }) { return leader->PaneId(); } return {}; } - TerminalConnection::ITerminalConnection TerminalPage::_HtmFocusedConnection() const + TerminalConnection::ITerminalConnection TerminalPage::_TmuxFocusedConnection() const { if (const auto tab{ _GetFocusedTabImpl() }) { @@ -6323,22 +6336,22 @@ namespace winrt::TerminalApp::implementation return nullptr; } - TerminalConnection::ITerminalConnection TerminalPage::_HtmAnyConnectionInWindow() const + TerminalConnection::ITerminalConnection TerminalPage::_TmuxAnyConnectionInWindow() const { // Prefer the focused pane, but CLI actions (``wt -w last split-pane``) // often land before XAML focus is on the TermControl. Fall back to any - // HTM leader/follower in this window so we never ConPTY-split an HTM pane. - if (const auto focused{ _HtmFocusedConnection() }) + // TMUX leader/follower in this window so we never ConPTY-split an TMUX pane. + if (const auto focused{ _TmuxFocusedConnection() }) { - // Follower before leader — see _HtmPaneIdFromConnection. - if (const auto follower{ focused.try_as() }) + // Follower before leader — see _TmuxPaneIdFromConnection. + if (const auto follower{ AsTmuxFollower(focused) }) { if (!follower->IsClosed()) { return focused; } } - else if (focused.try_as()) + else if (AsTmuxLeader(focused)) { return focused; } @@ -6361,7 +6374,7 @@ namespace winrt::TerminalApp::implementation return false; } const auto connection = control.Connection(); - if (const auto follower{ connection.try_as() }) + if (const auto follower{ AsTmuxFollower(connection) }) { if (!follower->IsClosed()) { @@ -6370,7 +6383,7 @@ namespace winrt::TerminalApp::implementation } return false; } - if (connection.try_as()) + if (AsTmuxLeader(connection)) { found = connection; return true; @@ -6387,7 +6400,7 @@ namespace winrt::TerminalApp::implementation return nullptr; } - std::shared_ptr TerminalPage::_HtmFindPane(const std::string& paneId) const + std::shared_ptr TerminalPage::_TmuxFindPane(const std::string& paneId) const { if (paneId.empty()) { @@ -6397,27 +6410,30 @@ namespace winrt::TerminalApp::implementation { if (const auto tabImpl{ _GetTabImpl(tab) }) { - if (const auto pane{ tabImpl->GetRootPane()->_FindPane([&](const auto& candidate) { - const auto control = candidate->GetTerminalControl(); - if (!control) - { - return false; - } - return _HtmPaneIdFromConnection(control.Connection()) == paneId; - }) }) + if (const auto root{ tabImpl->GetRootPane() }) { - return pane; + if (const auto pane{ root->_FindPane([&](const auto& candidate) { + const auto control = candidate->GetTerminalControl(); + if (!control) + { + return false; + } + return _TmuxPaneIdFromConnection(control.Connection()) == paneId; + }) }) + { + return pane; + } } } } return nullptr; } - void TerminalPage::_HtmSplitExisting(const std::string& sourcePaneId, + void TerminalPage::_TmuxSplitExisting(const std::string& sourcePaneId, TerminalConnection::ITerminalConnection follower, bool vertical) { - auto sourcePane = _HtmFindPane(sourcePaneId); + auto sourcePane = _TmuxFindPane(sourcePaneId); winrt::com_ptr tabImpl; if (sourcePane) { @@ -6439,28 +6455,28 @@ namespace winrt::TerminalApp::implementation // Never split the tmux -CC gateway; if the home pane is not ready // yet, open the new follower as its own tab instead. if (!focused->GetActiveTerminalControl() || - !focused->GetActiveTerminalControl().Connection().try_as()) + !AsTmuxLeader(focused->GetActiveTerminalControl().Connection())) { tabImpl = focused; } } if (!tabImpl) { - _HtmOpenFollowerAsTab(follower); + _TmuxOpenFollowerAsTab(follower); return; } winrt::TerminalApp::Tab sourceTab{ *tabImpl }; auto newPane = _MakeTerminalPane(nullptr, sourceTab, follower); if (!newPane) { - _HtmOpenFollowerAsTab(follower); + _TmuxOpenFollowerAsTab(follower); return; } const auto direction = vertical ? SplitDirection::Right : SplitDirection::Down; _SplitPane(tabImpl, direction, 0.5f, newPane); } - void TerminalPage::_HtmNewWindow(TerminalConnection::ITerminalConnection follower) + void TerminalPage::_TmuxNewWindow(TerminalConnection::ITerminalConnection follower) { // Always a new OS window (gateway stays a control plane). Used for // ShortcutAction::NewWindow and server-driven new-window panes. @@ -6474,21 +6490,22 @@ namespace winrt::TerminalApp::implementation RequestNewWindow.raise(*this, request); } - void TerminalPage::_HtmNewTab(TerminalConnection::ITerminalConnection follower) + void TerminalPage::_TmuxNewTab(TerminalConnection::ITerminalConnection follower) { if (!follower) { return; } - // Adding a tab on this page (must already be a native HTM host window). - if (auto* session{ _HtmSessionForConnection(follower) }) + // Adding a tab on this page (must already be a native TMUX host window). + if (auto* session{ _TmuxSessionForConnection(follower) }) { + session->SetFollowerAffinityHost(follower, _TmuxPaneIdFromConnection(_TmuxAnyConnectionInWindow())); session->RegisterFollowerPage(this); } auto newPane = _MakeTerminalPane(nullptr, nullptr, follower); if (!newPane) { - _HtmNewWindow(std::move(follower)); + _TmuxNewWindow(std::move(follower)); return; } newPane->WalkTree([](const auto& pane) { @@ -6497,47 +6514,47 @@ namespace winrt::TerminalApp::implementation _CreateNewTabFromPane(newPane); } - void TerminalPage::_HtmOpenFollowerAsTab(TerminalConnection::ITerminalConnection follower) + void TerminalPage::_TmuxOpenFollowerAsTab(TerminalConnection::ITerminalConnection follower) { if (!follower) { return; } - if (auto* session{ _HtmSessionForConnection(follower) }) + if (auto* session{ _TmuxSessionForConnection(follower) }) { - // If this window already hosts HTM followers, tab here directly. - if (_HtmAnyConnectionInWindow().try_as()) + // If this window already hosts TMUX followers, tab here directly. + if (AsTmuxFollower(_TmuxAnyConnectionInWindow())) { - _HtmNewTab(std::move(follower)); + _TmuxNewTab(std::move(follower)); return; } session->OpenFollowerAsTab(follower); return; } - _HtmNewWindow(std::move(follower)); + _TmuxNewWindow(std::move(follower)); } - void TerminalPage::_HtmOpenFollowerAsWindow(TerminalConnection::ITerminalConnection follower) + void TerminalPage::_TmuxOpenFollowerAsWindow(TerminalConnection::ITerminalConnection follower) { if (!follower) { return; } - if (auto* session{ _HtmSessionForConnection(follower) }) + if (auto* session{ _TmuxSessionForConnection(follower) }) { session->OpenFollowerAsWindow(follower); return; } - _HtmNewWindow(std::move(follower)); + _TmuxNewWindow(std::move(follower)); } - bool TerminalPage::_HtmClosePane(const std::string& paneId) + bool TerminalPage::_TmuxClosePane(const std::string& paneId) { - if (auto pane{ _HtmFindPane(paneId) }) + if (auto pane{ _TmuxFindPane(paneId) }) { if (const auto control{ pane->GetTerminalControl() }) { - if (const auto follower{ control.Connection().try_as() }) + if (const auto follower{ AsTmuxFollower(control.Connection()) }) { follower->SetSuppressClosePacket(true); } @@ -6548,7 +6565,7 @@ namespace winrt::TerminalApp::implementation return false; } - bool TerminalPage::_HtmSetTabTitleForPane(const std::string& paneId, const winrt::hstring& title) + bool TerminalPage::_TmuxSetTabTitleForPane(const std::string& paneId, const winrt::hstring& title) { if (paneId.empty() || title.empty()) { @@ -6566,7 +6583,7 @@ namespace winrt::TerminalApp::implementation { return false; } - return _HtmPaneIdFromConnection(control.Connection()) == paneId; + return _TmuxPaneIdFromConnection(control.Connection()) == paneId; }); if (found) { @@ -6579,23 +6596,23 @@ namespace winrt::TerminalApp::implementation return false; } - HtmSession* TerminalPage::_HtmSessionForConnection(const TerminalConnection::ITerminalConnection& connection) const + TmuxSession* TerminalPage::_TmuxSessionForConnection(const TerminalConnection::ITerminalConnection& connection) const { - // Follower before leader — see _HtmPaneIdFromConnection. + // Follower before leader — see _TmuxPaneIdFromConnection. if (connection) { - if (const auto follower{ connection.try_as() }) + if (const auto follower{ AsTmuxFollower(connection) }) { return follower->Session(); } - if (const auto leader{ connection.try_as() }) + if (const auto leader{ AsTmuxLeader(connection) }) { return leader->Session(); } } - if (_htmSession && _htmSession->IsActive()) + if (_tmuxSession && _tmuxSession->IsActive()) { - return _htmSession.get(); + return _tmuxSession.get(); } return nullptr; } diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index 66c846c8a0c..d025d167026 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -18,7 +18,7 @@ #include "WindowListRequest.g.h" #include "Toast.h" -#include "HtmSession.h" +#include "TmuxSession.h" #include "WindowsPackageManagerFactory.h" #define DECLARE_ACTION_HANDLER(action) void _Handle##action(const IInspectable& sender, const Microsoft::Terminal::Settings::Model::ActionEventArgs& args); @@ -144,7 +144,7 @@ namespace winrt::TerminalApp::implementation struct TerminalPage : TerminalPageT { - friend class HtmSession; + friend class TmuxSession; public: TerminalPage(TerminalApp::WindowProperties properties, const TerminalApp::ContentManager& manager); @@ -291,7 +291,7 @@ namespace winrt::TerminalApp::implementation winrt::TerminalApp::ColorPickupFlyout _tabColorPicker{ nullptr }; Microsoft::Terminal::Settings::Model::CascadiaSettings _settings{ nullptr }; - std::unique_ptr _htmSession; + std::unique_ptr _tmuxSession; Windows::Foundation::Collections::IObservableVector _tabs; Windows::Foundation::Collections::IObservableVector _mruTabs; @@ -388,18 +388,18 @@ namespace winrt::TerminalApp::implementation winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection _duplicateConnectionForRestart(const TerminalApp::TerminalPaneContent& paneContent); void _restartPaneConnection(const TerminalApp::TerminalPaneContent&, const winrt::Windows::Foundation::IInspectable&); - void _HtmSplitExisting(const std::string& sourcePaneId, winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower, bool vertical); - void _HtmNewWindow(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower); - void _HtmNewTab(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower); - void _HtmOpenFollowerAsTab(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower); - void _HtmOpenFollowerAsWindow(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower); - bool _HtmClosePane(const std::string& paneId); - bool _HtmSetTabTitleForPane(const std::string& paneId, const winrt::hstring& title); - HtmSession* _HtmSessionForConnection(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection) const; - std::string _HtmPaneIdFromConnection(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection) const; - winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection _HtmFocusedConnection() const; - winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection _HtmAnyConnectionInWindow() const; - std::shared_ptr _HtmFindPane(const std::string& paneId) const; + void _TmuxSplitExisting(const std::string& sourcePaneId, winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower, bool vertical); + void _TmuxNewWindow(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower); + void _TmuxNewTab(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower); + void _TmuxOpenFollowerAsTab(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower); + void _TmuxOpenFollowerAsWindow(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection follower); + bool _TmuxClosePane(const std::string& paneId); + bool _TmuxSetTabTitleForPane(const std::string& paneId, const winrt::hstring& title); + TmuxSession* _TmuxSessionForConnection(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection) const; + std::string _TmuxPaneIdFromConnection(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection) const; + winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection _TmuxFocusedConnection() const; + winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection _TmuxAnyConnectionInWindow() const; + std::shared_ptr _TmuxFindPane(const std::string& paneId) const; void _OpenNewWindow(const Microsoft::Terminal::Settings::Model::INewContentArgs& contentArgs); void _OpenWorkspaceWindow(const winrt::hstring name); diff --git a/src/cascadia/TerminalApp/TerminalPaneContent.cpp b/src/cascadia/TerminalApp/TerminalPaneContent.cpp index 2ab2b968647..71ef8ce1a66 100644 --- a/src/cascadia/TerminalApp/TerminalPaneContent.cpp +++ b/src/cascadia/TerminalApp/TerminalPaneContent.cpp @@ -6,7 +6,7 @@ #include -#include "HtmConnections.h" +#include "TmuxConnections.h" #include "TerminalSettingsCache.h" #include "../../types/inc/utils.hpp" @@ -247,14 +247,14 @@ namespace winrt::TerminalApp::implementation closePane = true; } } - // HTM followers are virtual mux panes: when ForceCloseUi / kill-pane marks + // TMUX followers are virtual mux panes: when ForceCloseUi / kill-pane marks // them Closed, the TermControl must tear down even if closeOnExit is never // (otherwise detach leaves inert native windows the e2e had to WM_CLOSE). if (!closePane && - Feature_HtmIntegration::IsEnabled() && + Feature_TmuxIntegration::IsEnabled() && newConnectionState == ConnectionState::Closed && _control && - _control.Connection().try_as()) + AsTmuxFollower(_control.Connection())) { closePane = true; } diff --git a/src/cascadia/TerminalApp/HtmConnections.cpp b/src/cascadia/TerminalApp/TmuxConnections.cpp similarity index 82% rename from src/cascadia/TerminalApp/HtmConnections.cpp rename to src/cascadia/TerminalApp/TmuxConnections.cpp index cbbe745667d..77d860a186d 100644 --- a/src/cascadia/TerminalApp/HtmConnections.cpp +++ b/src/cascadia/TerminalApp/TmuxConnections.cpp @@ -2,22 +2,22 @@ // Licensed under the MIT license. #include "pch.h" -#include "HtmConnections.h" -#include "HtmSession.h" +#include "TmuxConnections.h" +#include "TmuxSession.h" #include using namespace winrt::Microsoft::Terminal::TerminalConnection; -using namespace ::Microsoft::Terminal::Htm; +using namespace ::Microsoft::Terminal::Tmux; namespace winrt::TerminalApp::implementation { - HtmLeaderConnection::HtmLeaderConnection(ITerminalConnection wrapped, HtmSession* session) : + TmuxLeaderConnection::TmuxLeaderConnection(ITerminalConnection wrapped, TmuxSession* session) : _wrapped{ wrapped }, _sessionId{ wrapped.SessionId() }, _session{ session } { - _outputRevoker = _wrapped.TerminalOutput(winrt::auto_revoke, { get_weak(), &HtmLeaderConnection::_OutputHandler }); + _outputRevoker = _wrapped.TerminalOutput(winrt::auto_revoke, { get_weak(), &TmuxLeaderConnection::_OutputHandler }); _stateChangedRevoker = _wrapped.StateChanged(winrt::auto_revoke, [weak = get_weak()](auto&&, auto&&) { if (const auto self = weak.get()) { @@ -26,19 +26,19 @@ namespace winrt::TerminalApp::implementation }); } - void HtmLeaderConnection::Initialize(const Windows::Foundation::Collections::ValueSet& settings) + void TmuxLeaderConnection::Initialize(const Windows::Foundation::Collections::ValueSet& settings) { _wrapped.Initialize(settings); } - void HtmLeaderConnection::Start() + void TmuxLeaderConnection::Start() { _wrapped.Start(); } - void HtmLeaderConnection::WriteInput(const winrt::array_view data) + void TmuxLeaderConnection::WriteInput(const winrt::array_view data) { - if (_htmMode) + if (_tmuxMode) { // Stateful conversion: KEYEVENTF_UNICODE may deliver one surrogate // per WriteInput; til::u16u8 without state would emit CESU-8. @@ -62,10 +62,10 @@ namespace winrt::TerminalApp::implementation _wrapped.WriteInput(data); } - void HtmLeaderConnection::Resize(uint32_t rows, uint32_t columns) + void TmuxLeaderConnection::Resize(uint32_t rows, uint32_t columns) { _wrapped.Resize(rows, columns); - if (!_htmMode || !_session || rows == 0 || columns == 0) + if (!_tmuxMode || !_session || rows == 0 || columns == 0) { return; } @@ -99,14 +99,14 @@ namespace winrt::TerminalApp::implementation std::chrono::milliseconds{ 75 }); } - void HtmLeaderConnection::_flushPendingClientSize() + void TmuxLeaderConnection::_flushPendingClientSize() { - HtmSession* session = nullptr; + TmuxSession* session = nullptr; uint32_t rows = 0; uint32_t cols = 0; { std::lock_guard lock{ _stateMutex }; - if (_closed || !_htmMode || !_session || _rows == 0 || _cols == 0) + if (_closed || !_tmuxMode || !_session || _rows == 0 || _cols == 0) { return; } @@ -123,14 +123,14 @@ namespace winrt::TerminalApp::implementation session->WriteToLeader("refresh-client -C " + std::to_string(cols) + "x" + std::to_string(rows)); } - void HtmLeaderConnection::Close() + void TmuxLeaderConnection::Close() { { std::lock_guard lock{ _stateMutex }; ++_resizeGeneration; } _closed = true; - if (_session && _htmMode) + if (_session && _tmuxMode) { _session->DetachLeader(this); } @@ -143,24 +143,24 @@ namespace winrt::TerminalApp::implementation _wrapped = nullptr; } - winrt::guid HtmLeaderConnection::SessionId() const noexcept + winrt::guid TmuxLeaderConnection::SessionId() const noexcept { return _sessionId; } - ConnectionState HtmLeaderConnection::State() const noexcept + ConnectionState TmuxLeaderConnection::State() const noexcept { return _closed ? ConnectionState::Closed : ConnectionState::Connected; } - void HtmLeaderConnection::WriteRaw(std::string_view bytes) + void TmuxLeaderConnection::WriteRaw(std::string_view bytes) { if (bytes.empty()) { return; } // Pane input, resizes, and app actions can arrive on different UI and - // connection threads. Keep each HTM frame in one ConPTY write so a + // connection threads. Keep each TMUX frame in one ConPTY write so a // resize cannot splice itself into a key or split packet. try { @@ -178,7 +178,7 @@ namespace winrt::TerminalApp::implementation } } - void HtmLeaderConnection::InjectOutput(std::string_view utf8) + void TmuxLeaderConnection::InjectOutput(std::string_view utf8) { if (utf8.empty()) { @@ -188,9 +188,9 @@ namespace winrt::TerminalApp::implementation TerminalOutput.raise(winrt_wstring_to_array_view(wide)); } - void HtmLeaderConnection::ForceCloseClient() + void TmuxLeaderConnection::ForceCloseClient() { - _htmMode = false; + _tmuxMode = false; _session = nullptr; _outputRevoker.revoke(); _stateChangedRevoker.revoke(); @@ -203,27 +203,27 @@ namespace winrt::TerminalApp::implementation StateChanged.raise(*this, nullptr); } - void HtmLeaderConnection::_OutputHandler(const winrt::array_view str) + void TmuxLeaderConnection::_OutputHandler(const winrt::array_view str) { const auto utf8 = til::u16u8(winrt_array_to_wstring_view(str)); - const auto carrier = DecodeConPtyHtmCarrier(_carrierPending, utf8); + const auto carrier = DecodeConPtyTmuxCarrier(_carrierPending, utf8); _carrierPending = carrier.pending; if (carrier.decoded.empty()) { return; } - if (_htmMode) + if (_tmuxMode) { if (carrier.decoded.find(TmuxControlSt) != std::string::npos) { - _htmMode = false; + _tmuxMode = false; if (_session) { _session->HandleExitSequence(); } return; } - _ProcessHtmBytes(carrier.decoded); + _ProcessTmuxBytes(carrier.decoded); return; } @@ -248,21 +248,21 @@ namespace winrt::TerminalApp::implementation } const auto remainder = _pendingInit.substr(marker + TmuxControlDcs.size()); _pendingInit.clear(); - _htmMode = true; + _tmuxMode = true; if (_session) _session->AttachLeader(this); if (!remainder.empty()) - _ProcessHtmBytes(remainder); + _ProcessTmuxBytes(remainder); } - void HtmLeaderConnection::_ProcessHtmBytes(std::string_view utf8) + void TmuxLeaderConnection::_ProcessTmuxBytes(std::string_view utf8) { - _htmBuffer.append(utf8); + _tmuxBuffer.append(utf8); size_t newline = 0; - while ((newline = _htmBuffer.find('\n')) != std::string::npos) + while ((newline = _tmuxBuffer.find('\n')) != std::string::npos) { - auto line = _htmBuffer.substr(0, newline); - _htmBuffer.erase(0, newline + 1); + auto line = _tmuxBuffer.substr(0, newline); + _tmuxBuffer.erase(0, newline + 1); if (!line.empty() && line.back() == '\r') line.pop_back(); if (_session) @@ -270,15 +270,15 @@ namespace winrt::TerminalApp::implementation } } - HtmFollowerConnection::HtmFollowerConnection(HtmSession* session, std::string paneId) : + TmuxFollowerConnection::TmuxFollowerConnection(TmuxSession* session, std::string paneId) : _session{ session }, _paneId{ std::move(paneId) } { } - void HtmFollowerConnection::Start() + void TmuxFollowerConnection::Start() { - HtmSession* session = nullptr; + TmuxSession* session = nullptr; std::string paneId; std::wstring pendingWide; uint32_t rows = 0; @@ -316,7 +316,7 @@ namespace winrt::TerminalApp::implementation } } - void HtmFollowerConnection::WriteInput(const winrt::array_view data) + void TmuxFollowerConnection::WriteInput(const winrt::array_view data) { if (!_session || _closed) { @@ -337,7 +337,7 @@ namespace winrt::TerminalApp::implementation _session->SendKeys(_paneId, keys); } - void HtmFollowerConnection::Resize(uint32_t rows, uint32_t columns) + void TmuxFollowerConnection::Resize(uint32_t rows, uint32_t columns) { // TermControl may report 0x0 during first layout; never push that to htmd. if (rows == 0 || columns == 0) @@ -377,9 +377,9 @@ namespace winrt::TerminalApp::implementation std::chrono::milliseconds{ 75 }); } - void HtmFollowerConnection::_flushPendingResize() + void TmuxFollowerConnection::_flushPendingResize() { - HtmSession* session = nullptr; + TmuxSession* session = nullptr; std::string paneId; uint32_t rows = 0; uint32_t cols = 0; @@ -403,9 +403,9 @@ namespace winrt::TerminalApp::implementation session->WriteToLeader("resize-pane -t " + paneId + " -x " + std::to_string(cols) + " -y " + std::to_string(rows)); } - void HtmFollowerConnection::SetPaneId(std::string paneId) + void TmuxFollowerConnection::SetPaneId(std::string paneId) { - HtmSession* session = nullptr; + TmuxSession* session = nullptr; uint32_t rows = 0; uint32_t cols = 0; { @@ -427,7 +427,7 @@ namespace winrt::TerminalApp::implementation } } - void HtmFollowerConnection::Close() + void TmuxFollowerConnection::Close() { if (_session) { @@ -442,7 +442,7 @@ namespace winrt::TerminalApp::implementation StateChanged.raise(*this, nullptr); } - void HtmFollowerConnection::ForceCloseUi() + void TmuxFollowerConnection::ForceCloseUi() { { std::lock_guard lock{ _stateMutex }; @@ -462,7 +462,7 @@ namespace winrt::TerminalApp::implementation } } - void HtmFollowerConnection::InjectOutput(std::string_view utf8) + void TmuxFollowerConnection::InjectOutput(std::string_view utf8) { if (utf8.empty()) { diff --git a/src/cascadia/TerminalApp/HtmConnections.h b/src/cascadia/TerminalApp/TmuxConnections.h similarity index 67% rename from src/cascadia/TerminalApp/HtmConnections.h rename to src/cascadia/TerminalApp/TmuxConnections.h index c33bf56f818..1035fa06c50 100644 --- a/src/cascadia/TerminalApp/HtmConnections.h +++ b/src/cascadia/TerminalApp/TmuxConnections.h @@ -3,7 +3,7 @@ #pragma once -#include "HtmProtocol.h" +#include "TmuxProtocol.h" #include #include @@ -13,13 +13,23 @@ namespace winrt::TerminalApp::implementation { - class HtmSession; + class TmuxSession; - class HtmLeaderConnection : public winrt::implements + struct __declspec(uuid("6B5B3E45-97F1-4F18-97F3-7EE92B1C2381")) + ITmuxLeaderMarker : ::IUnknown + { + }; + + struct __declspec(uuid("5A4A2B34-8C12-4E09-B812-4DD12A34B456")) + ITmuxFollowerMarker : ::IUnknown + { + }; + + class TmuxLeaderConnection : public winrt::implements { public: - HtmLeaderConnection(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection wrapped, - HtmSession* session); + TmuxLeaderConnection(winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection wrapped, + TmuxSession* session); void Initialize(const Windows::Foundation::Collections::ValueSet& settings); void Start(); @@ -33,8 +43,8 @@ namespace winrt::TerminalApp::implementation void WriteRaw(std::string_view bytes); void InjectOutput(std::string_view utf8); void ForceCloseClient(); - bool InHtmMode() const noexcept { return _htmMode; } - HtmSession* Session() const noexcept { return _session; } + bool InTmuxMode() const noexcept { return _tmuxMode; } + TmuxSession* Session() const noexcept { return _session; } void SetPaneId(std::string paneId) { std::lock_guard lock{ _stateMutex }; @@ -51,25 +61,25 @@ namespace winrt::TerminalApp::implementation private: void _OutputHandler(const winrt::array_view str); - void _ProcessHtmBytes(std::string_view utf8); + void _ProcessTmuxBytes(std::string_view utf8); winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection _wrapped{ nullptr }; winrt::guid _sessionId{}; winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection::TerminalOutput_revoker _outputRevoker; winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection::StateChanged_revoker _stateChangedRevoker; - HtmSession* _session{ nullptr }; - bool _htmMode{ false }; + TmuxSession* _session{ nullptr }; + bool _tmuxMode{ false }; std::string _pendingInit; std::string _carrierPending; - std::string _htmBuffer; - mutable std::mutex _stateMutex; + std::string _tmuxBuffer; + mutable std::recursive_mutex _stateMutex; std::string _paneId; std::mutex _writeMutex; bool _closed{ false }; // SendInput KEYEVENTF_UNICODE delivers one UTF-16 code unit per call; // hold high surrogates across WriteInput so emoji becomes real UTF-8. til::u16state _u16ToUtf8; - ::Microsoft::Terminal::Htm::Win32InputDecodeState _win32Decode; + ::Microsoft::Terminal::Tmux::Win32InputDecodeState _win32Decode; uint32_t _rows{ 24 }; uint32_t _cols{ 80 }; uint32_t _flushedRows{ 0 }; @@ -78,10 +88,10 @@ namespace winrt::TerminalApp::implementation void _flushPendingClientSize(); }; - class HtmFollowerConnection : public winrt::implements + class TmuxFollowerConnection : public winrt::implements { public: - HtmFollowerConnection(HtmSession* session, std::string paneId); + TmuxFollowerConnection(TmuxSession* session, std::string paneId); void Initialize(const Windows::Foundation::Collections::ValueSet& /*settings*/) {}; void Start(); @@ -96,7 +106,7 @@ namespace winrt::TerminalApp::implementation winrt::Microsoft::Terminal::TerminalConnection::ConnectionState::Connected; } - HtmSession* Session() const noexcept { return _session; } + TmuxSession* Session() const noexcept { return _session; } std::string PaneId() const noexcept { std::lock_guard lock{ _stateMutex }; @@ -115,7 +125,7 @@ namespace winrt::TerminalApp::implementation _suppressClosePacket = value; } // Stop accepting output/input without raising StateChanged; the page - // still owns the TermControl and will close it via _HtmClosePane. + // still owns the TermControl and will close it via _TmuxClosePane. void SilenceForDetach() noexcept { std::lock_guard lock{ _stateMutex }; @@ -131,8 +141,8 @@ namespace winrt::TerminalApp::implementation til::typed_event StateChanged; private: - HtmSession* _session{ nullptr }; - mutable std::mutex _stateMutex; + TmuxSession* _session{ nullptr }; + mutable std::recursive_mutex _stateMutex; std::string _paneId; bool _started{ false }; bool _suppressClosePacket{ false }; @@ -141,7 +151,7 @@ namespace winrt::TerminalApp::implementation // SendInput KEYEVENTF_UNICODE delivers one UTF-16 code unit per call; // hold high surrogates across WriteInput so emoji becomes real UTF-8. til::u16state _u16ToUtf8; - ::Microsoft::Terminal::Htm::Win32InputDecodeState _win32Decode; + ::Microsoft::Terminal::Tmux::Win32InputDecodeState _win32Decode; uint32_t _rows{ 24 }; uint32_t _cols{ 80 }; // Last size pushed to htmd. Split layout animates through many @@ -150,4 +160,28 @@ namespace winrt::TerminalApp::implementation uint32_t _flushedCols{ 0 }; uint32_t _resizeGeneration{ 0 }; }; + + inline TmuxLeaderConnection* AsTmuxLeader(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& conn) noexcept + { + if (conn) + { + if (const auto marker = conn.try_as()) + { + return winrt::get_self(marker); + } + } + return nullptr; + } + + inline TmuxFollowerConnection* AsTmuxFollower(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& conn) noexcept + { + if (conn) + { + if (const auto marker = conn.try_as()) + { + return winrt::get_self(marker); + } + } + return nullptr; + } } diff --git a/src/cascadia/TerminalApp/HtmProtocol.h b/src/cascadia/TerminalApp/TmuxProtocol.h similarity index 96% rename from src/cascadia/TerminalApp/HtmProtocol.h rename to src/cascadia/TerminalApp/TmuxProtocol.h index c230dfeda68..47f8fb498bd 100644 --- a/src/cascadia/TerminalApp/HtmProtocol.h +++ b/src/cascadia/TerminalApp/TmuxProtocol.h @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // -// HTM (headless terminal multiplexer) wire protocol, matching -// EternalTerminal HtmHeaderCodes and hyper-htm/htm-core.js. +// tmux control-mode wire protocol carried by EternalTerminal. #pragma once @@ -15,9 +14,9 @@ #include #include -namespace Microsoft::Terminal::Htm +namespace Microsoft::Terminal::Tmux { - // HTM now uses tmux control mode. These are terminal-facing markers; the + // tmux control mode uses these terminal-facing markers; the // bytes after DCS are ordinary newline-delimited tmux control records. inline constexpr std::string_view TmuxControlDcs{ "\x1bP1000p" }; inline constexpr std::string_view TmuxControlSt{ "\x1b\\" }; @@ -31,19 +30,19 @@ namespace Microsoft::Terminal::Htm " L Toggle logging.\r\n" " C Run tmux command.\r\n" }; - // ConPTY strips DCS. EternalTerminal's Windows htm client carries control + // ConPTY strips DCS. EternalTerminal's Windows client carries control // bytes as CSI ?777;b0;b1;...q (at most 15 payload bytes per sequence). - inline constexpr std::string_view ConPtyHtmCarrierPrefix{ "\x1b[?777" }; + inline constexpr std::string_view ConPtyTmuxCarrierPrefix{ "\x1b[?777" }; inline size_t LongestInitPrefix(std::string_view data, std::string_view needle); - inline std::string EncodeConPtyHtmCarrier(std::string_view bytes) + inline std::string EncodeConPtyTmuxCarrier(std::string_view bytes) { std::string out; constexpr size_t chunkSize = 15; for (size_t offset = 0; offset < bytes.size(); offset += chunkSize) { const auto end = std::min(bytes.size(), offset + chunkSize); - out.append(ConPtyHtmCarrierPrefix); + out.append(ConPtyTmuxCarrierPrefix); for (size_t i = offset; i < end; ++i) { out.push_back(';'); @@ -60,7 +59,7 @@ namespace Microsoft::Terminal::Htm std::string pending; }; - inline CarrierDecodeResult DecodeConPtyHtmCarrier(std::string_view pending, std::string_view incoming) + inline CarrierDecodeResult DecodeConPtyTmuxCarrier(std::string_view pending, std::string_view incoming) { std::string data; data.reserve(pending.size() + incoming.size()); @@ -70,16 +69,16 @@ namespace Microsoft::Terminal::Htm size_t i = 0; while (i < data.size()) { - const auto pos = data.find(ConPtyHtmCarrierPrefix, i); + const auto pos = data.find(ConPtyTmuxCarrierPrefix, i); if (pos == std::string::npos) { - const auto keep = LongestInitPrefix(std::string_view{ data }.substr(i), ConPtyHtmCarrierPrefix); + const auto keep = LongestInitPrefix(std::string_view{ data }.substr(i), ConPtyTmuxCarrierPrefix); result.decoded.append(data.substr(i, data.size() - i - keep)); result.pending = data.substr(data.size() - keep); return result; } result.decoded.append(data.substr(i, pos - i)); - size_t cursor = pos + ConPtyHtmCarrierPrefix.size(); + size_t cursor = pos + ConPtyTmuxCarrierPrefix.size(); std::string payload; bool complete = false; bool invalid = false; diff --git a/src/cascadia/TerminalApp/TmuxSession.cpp b/src/cascadia/TerminalApp/TmuxSession.cpp new file mode 100644 index 00000000000..d4ec8800aa7 --- /dev/null +++ b/src/cascadia/TerminalApp/TmuxSession.cpp @@ -0,0 +1,1229 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#include "pch.h" +#include "TmuxSession.h" +#include "TmuxConnections.h" +#include "TerminalPage.h" + +#include + +using namespace winrt::Microsoft::Terminal::TerminalConnection; +using namespace ::Microsoft::Terminal::Tmux; + +namespace winrt::TerminalApp::implementation +{ + void TmuxSession::SetNativeHostPage(TerminalPage* page) noexcept + { + std::lock_guard lock{ _mutex }; + if (!_nativeHostPage && page) + { + _nativeHostPage = page->get_weak(); + } + } + + TerminalPage* TmuxSession::NativeHostPage() const noexcept + { + std::lock_guard lock{ _mutex }; + if (const auto host = _nativeHostPage.get()) + { + return host.get(); + } + return nullptr; + } + + void TmuxSession::ClearNativeHostPage(TerminalPage* page) noexcept + { + std::lock_guard lock{ _mutex }; + if (const auto host = _nativeHostPage.get(); host && host.get() == page) + { + _nativeHostPage = nullptr; + } + std::erase_if(_followerPages, [page](const winrt::weak_ref& weak) { + const auto live = weak.get(); + return !live || live.get() == page; + }); + } + + void TmuxSession::RegisterFollowerPage(TerminalPage* page) noexcept + { + if (!page) + { + return; + } + std::lock_guard lock{ _mutex }; + for (const auto& weak : _followerPages) + { + if (const auto live = weak.get(); live && live.get() == page) + { + return; + } + } + _followerPages.push_back(page->get_weak()); + if (!_nativeHostPage) + { + _nativeHostPage = page->get_weak(); + } + } + + void TmuxSession::SetFollowerAffinityHost(const ITerminalConnection& follower, std::string_view sourcePaneId) + { + const auto pending = AsTmuxFollower(follower); + if (!pending) + { + return; + } + { + std::lock_guard lock{ _mutex }; + const auto source = _paneToWindow.find(std::string{ sourcePaneId }); + if (source == _paneToWindow.end()) + { + return; + } + const auto group = _windowAffinityGroup.find(source->second); + const auto& host = group == _windowAffinityGroup.end() ? source->second : group->second; + for (auto& queued : _pendingFollowers) + { + if (queued.connection == pending) + { + queued.affinityGroup = host; + return; + } + } + // The tmux reply may win the race with the UI tab construction. + // In that case the follower is already registered; update the + // newly-assigned mux window directly. + for (const auto& [paneId, live] : _followers) + { + if (live == pending) + { + const auto target = _paneToWindow.find(paneId); + if (target != _paneToWindow.end()) + { + _windowAffinityGroup[target->second] = host; + } + break; + } + } + } + _publishAffinities(); + } + + void TmuxSession::OpenFollowerAsTab(const ITerminalConnection& follower) + { + if (!follower) + { + return; + } + winrt::com_ptr host; + { + std::lock_guard lock{ _mutex }; + host = _nativeHostPage.get(); + } + if (host) + { + host->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [host, follower]() { + host->_TmuxNewTab(follower); + }); + return; + } + // No native host yet — first tab still needs an OS window to live in. + if (_page) + { + _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, follower]() { + _page->_TmuxNewWindow(follower); + }); + } + } + + void TmuxSession::OpenFollowerAsWindow(const ITerminalConnection& follower) + { + if (!follower || !_page) + { + return; + } + _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, follower]() { + _page->_TmuxNewWindow(follower); + }); + } + + TmuxSession::TmuxSession(TerminalPage* page) : _page{ page } {} + + void TmuxSession::AttachLeader(TmuxLeaderConnection* leader) + { + { + std::lock_guard lock{ _mutex }; + _leader = leader; + _detaching = false; + _loadingPersistedAffinities = true; + _skipPreAffinityReply = true; + } + // DCS is detected inside ConptyConnection's output callback. Queue the + // first command so that callback can return before we call WriteInput + // on the same connection. + const auto weakLeader = leader->get_weak(); + _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, weakLeader]() { + if (const auto leader = weakLeader.get(); leader && _leader == leader.get() && + leader->State() == ConnectionState::Connected) + { + leader->InjectOutput(std::string{ TmuxCommandMenu }); + // @affinities is session state owned by TMUX, so it survives + // the native client that published it. Read it before the + // refresh-client layout notifications reconstruct followers. + // The control transport is replaced after detach, while the + // mux session (and its live @affinities option) remains. + // Query that session-scoped value: it is also what the pane + // dump reads, so replay cannot disagree with live state. + WriteToLeader("show-options -qv @affinities"); + } + }); + } + + void TmuxSession::DetachLeader(TmuxLeaderConnection* leader) + { + if (_leader == leader) + { + _leader = nullptr; + // Snapshot/close native follower windows before clearing maps so we + // do not leave TermControls writing through a torn-down session. + _closeFollowerUi(); + _exitTmuxMode(); + } + } + + bool TmuxSession::IsActive() const noexcept + { + std::lock_guard lock{ _mutex }; + return _leader != nullptr; + } + + bool TmuxSession::IsTmuxConnection(const ITerminalConnection& connection) const + { + if (const auto follower{ AsTmuxFollower(connection) }) + return _followers.contains(follower->PaneId()); + if (const auto leader{ AsTmuxLeader(connection) }) + return leader == _leader; + return false; + } + + std::string TmuxSession::LeaderPaneId() const + { + std::lock_guard lock{ _mutex }; + auto live = [&](const std::string& id) -> bool { + const auto it = _followers.find(id); + return it != _followers.end() && it->second && !it->second->IsClosed(); + }; + if (!_homePaneId.empty() && live(_homePaneId)) + { + return _homePaneId; + } + for (const auto& [id, follower] : _followers) + { + if (follower && !follower->IsClosed()) + { + return id; + } + } + if (_leader) + { + auto id = _leader->PaneId(); + if (!id.empty()) + { + return id; + } + } + return "%0"; + } + + void TmuxSession::RegisterFollower(TmuxFollowerConnection* follower) + { + if (follower && !follower->PaneId().empty()) + { + std::lock_guard lock{ _mutex }; + _followers[follower->PaneId()] = follower; + } + } + + void TmuxSession::UnregisterFollower(TmuxFollowerConnection* follower) + { + if (follower) + { + std::lock_guard lock{ _mutex }; + _followers.erase(follower->PaneId()); + } + } + + bool TmuxSession::HasFollower(const std::string& paneId) const + { + std::lock_guard lock{ _mutex }; + const auto it = _followers.find(paneId); + return it != _followers.end() && it->second && !it->second->IsClosed(); + } + + std::string TmuxSession::FirstLiveFollowerPaneId() const + { + std::lock_guard lock{ _mutex }; + for (const auto& [id, follower] : _followers) + { + if (follower && !follower->IsClosed()) + { + return id; + } + } + return {}; + } + + void TmuxSession::WriteToLeader(std::string_view command) + { + if (!_leader) + { + return; + } + std::string line{ command }; + // A Windows console in line-input mode submits on CR, not LF. + // htmd accepts CR, LF, and CRLF as tmux command delimiters. + if (line.empty() || (line.back() != '\r' && line.back() != '\n')) + line.push_back('\r'); + _logProtocol(">", command); + // Never WriteInput the leader ConPTY on the UI thread or nested inside + // the leader's TerminalOutput handler. Action handlers (split/new-tab) + // and follower Start/Resize otherwise deadlock the window ("Not + // Responding") before htmd ever sees split-window. + try + { + const auto strongLeader = _leader->get_strong(); + std::thread([strongLeader, line = std::move(line)]() { + try + { + if (strongLeader) + { + strongLeader->WriteRaw(line); + } + } + catch (...) + { + } + }).detach(); + } + catch (...) + { + } + } + + void TmuxSession::SendKeys(std::string_view paneId, std::string_view utf8) + { + if (paneId.empty()) + return; + static constexpr char hex[] = "0123456789abcdef"; + std::string command{ "send-keys -H -t " }; + command += paneId; + for (unsigned char byte : utf8) + { + command += " 0x"; + command += hex[byte >> 4]; + command += hex[byte & 15]; + } + WriteToLeader(command); + } + + void TmuxSession::HandleLine(std::string_view line) + { + _logProtocol("<", line); + if (line.rfind("%output ", 0) == 0) + { + if (_detaching) + { + return; + } + const auto first = line.find(' ', 8); + if (first != std::string_view::npos) + _appendToPane(std::string{ line.substr(8, first - 8) }, UnescapeControlOutput(line.substr(first + 1))); + return; + } + if (line.rfind("%window-pane-changed ", 0) == 0) + { + // "%window-pane-changed @0 %1" + const auto body = line.substr(21); + const auto space = body.find(' '); + std::string windowId; + std::string paneId; + if (space == std::string_view::npos) + { + paneId = std::string{ body }; + } + else + { + windowId = std::string{ body.substr(0, space) }; + paneId = std::string{ body.substr(space + 1) }; + } + if (paneId.empty()) + { + return; + } + TmuxFollowerConnection* follower = nullptr; + std::string affinityGroup; + bool separateNativeWindow = false; + { + std::lock_guard lock{ _mutex }; + if (!windowId.empty()) + { + _paneToWindow[paneId] = windowId; + if (const auto hint = _paneAffinityHint.find(paneId); hint != _paneAffinityHint.end()) + { + auto affinityGroup = hint->second; + if (affinityGroup.empty() && !_separateNativePane.contains(paneId)) + { + for (const auto& [otherPane, existingWindow] : _paneToWindow) + { + if (otherPane != paneId && existingWindow != windowId) + { + const auto group = _windowAffinityGroup.find(existingWindow); + affinityGroup = group == _windowAffinityGroup.end() ? existingWindow : group->second; + break; + } + } + } + _windowAffinityGroup[windowId] = affinityGroup.empty() ? windowId : affinityGroup; + _paneAffinityHint.erase(hint); + _separateNativePane.erase(paneId); + } + } + if (!_pendingFollowers.empty()) + { + follower = _pendingFollowers.front().connection; + affinityGroup = _pendingFollowers.front().affinityGroup; + separateNativeWindow = _pendingFollowers.front().separateNativeWindow; + _pendingFollowers.erase(_pendingFollowers.begin()); + _followers[paneId] = follower; + if (affinityGroup.empty() && !separateNativeWindow) + { + for (const auto& [_, existingWindow] : _paneToWindow) + { + if (existingWindow != windowId) + { + const auto group = _windowAffinityGroup.find(existingWindow); + affinityGroup = group == _windowAffinityGroup.end() ? existingWindow : group->second; + break; + } + } + } + if (!windowId.empty()) + { + _windowAffinityGroup[windowId] = affinityGroup.empty() ? windowId : affinityGroup; + } + } + } + if (follower) + { + follower->SetPaneId(paneId); + } + else + { + _ensureNativePane(paneId); + } + _publishAffinities(); + return; + } + if (line.rfind("%window-renamed ", 0) == 0) + { + // "%window-renamed @0 timeout" + const auto body = line.substr(16); + const auto space = body.find(' '); + if (space != std::string_view::npos && space + 1 < body.size()) + { + const std::string windowId{ body.substr(0, space) }; + const std::string name{ body.substr(space + 1) }; + _renameWindowTabs(windowId, name); + } + return; + } + if (line.rfind("%session-window-changed ", 0) == 0) + { + const auto space = line.rfind(' '); + if (space != std::string_view::npos && space + 1 < line.size()) + { + std::lock_guard lock{ _mutex }; + _activeWindowId = line.substr(space + 1); + } + return; + } + if (line.rfind("%layout-change ", 0) == 0) + { + // tmux does not send %window-pane-changed for a newly-created + // window. Its initial layout is necessarily a single leaf, whose + // final comma-separated field is the pane ID. Treat that + // authoritative notification as a fallback when the new-window + // command reply races follower startup or delivery. + const auto layoutBegin = line.find(' ', 15); + const auto layoutEnd = layoutBegin == std::string_view::npos ? std::string_view::npos : line.find(' ', layoutBegin + 1); + if (layoutBegin != std::string_view::npos && layoutEnd != std::string_view::npos) + { + const std::string windowId{ line.substr(15, layoutBegin - 15) }; + const auto layout = line.substr(layoutBegin + 1, layoutEnd - layoutBegin - 1); + // TMUX sends the complete layout before (and sometimes instead + // of) %window-pane-changed. Preserve its window-to-pane + // relationship so the terminal can publish @affinities. + if (!windowId.empty()) + { + std::lock_guard lock{ _mutex }; + for (const auto& paneId : PaneIdsFromTmuxLayout(layout)) + { + _paneToWindow[paneId] = windowId; + if (const auto hint = _paneAffinityHint.find(paneId); hint != _paneAffinityHint.end()) + { + auto affinityGroup = hint->second; + if (affinityGroup.empty() && !_separateNativePane.contains(paneId)) + { + for (const auto& [otherPane, existingWindow] : _paneToWindow) + { + if (otherPane != paneId && existingWindow != windowId) + { + const auto group = _windowAffinityGroup.find(existingWindow); + affinityGroup = group == _windowAffinityGroup.end() ? existingWindow : group->second; + break; + } + } + } + _windowAffinityGroup[windowId] = affinityGroup.empty() ? windowId : affinityGroup; + _paneAffinityHint.erase(hint); + _separateNativePane.erase(paneId); + } + } + } + _syncFollowersToLayout(layout, windowId); + const auto comma = layout.rfind(','); + if (comma != std::string_view::npos && + layout.find_first_of("[]{}") == std::string_view::npos) + { + const std::string paneId{ "%" + std::string{ layout.substr(comma + 1) } }; + TmuxFollowerConnection* follower = nullptr; + std::string affinityGroup; + bool separateNativeWindow = false; + bool splitInFlight = false; + { + std::lock_guard lock{ _mutex }; + if (!_pendingFollowers.empty() && _pendingFollowers.front().isTab) + { + follower = _pendingFollowers.front().connection; + affinityGroup = _pendingFollowers.front().affinityGroup; + separateNativeWindow = _pendingFollowers.front().separateNativeWindow; + _pendingFollowers.erase(_pendingFollowers.begin()); + _followers[paneId] = follower; + if (affinityGroup.empty() && !separateNativeWindow) + { + for (const auto& [_, existingWindow] : _paneToWindow) + { + if (existingWindow != windowId) + { + const auto group = _windowAffinityGroup.find(existingWindow); + affinityGroup = group == _windowAffinityGroup.end() ? existingWindow : group->second; + break; + } + } + } + if (!windowId.empty()) + { + _windowAffinityGroup[windowId] = affinityGroup.empty() ? windowId : affinityGroup; + } + } + else if (!_pendingFollowers.empty()) + { + // A user split is in flight; wait for %window-pane-changed + // or the -P reply rather than opening a duplicate tab. + splitInFlight = true; + } + } + if (follower) + { + follower->SetPaneId(paneId); + } + else if (!splitInFlight) + { + _ensureNativePane(paneId); + } + } + _publishAffinities(); + } + return; + } + if (line.rfind("%begin ", 0) == 0) + { + _inReply = true; + _replyLines.clear(); + return; + } + if (line.rfind("%end ", 0) == 0 || line.rfind("%error ", 0) == 0) + { + _finishReply(); + return; + } + if (line == "%exit") + { + _closeFollowerUi(); + _exitTmuxMode(); + return; + } + if (_inReply) + _replyLines.emplace_back(line); + } + + void TmuxSession::_finishReply() + { + _inReply = false; + bool refreshAfterAffinityLoad = false; + { + std::lock_guard lock{ _mutex }; + if (_loadingPersistedAffinities) + { + // Entering control mode can leave one empty command reply in + // flight ahead of our show-options request. Do not mistake it + // for an empty @affinities value and start layout replay early. + if (_skipPreAffinityReply && _replyLines.empty()) + { + _skipPreAffinityReply = false; + return; + } + _skipPreAffinityReply = false; + _loadingPersistedAffinities = false; + // Layout replay has no native-host ownership information. + // When the daemon restored an existing value, keep publication + // suppressed until a Windows Terminal tab/window action + // establishes fresh ownership. A new daemon replies with an + // empty show-options result; in that case the initial layout + // is authoritative and must seed @affinities immediately. + // The control transport is replaced on reattach, not this + // TmuxSession object. If the reply is empty because the + // socket's reply/notification ordering raced, its existing + // native-host map is still the authoritative restored state. + const bool hasPersistedAffinities = !_replyLines.empty() || !_windowAffinityGroup.empty(); + _suppressInitialAffinityPublish = hasPersistedAffinities; + if (hasPersistedAffinities) + { + // TMUX persists groups as "0,1,3 2,4". Restore only the + // group host relation; the refresh layout supplies the + // current pane-to-window relation immediately after this. + for (const auto& groupText : _replyLines) + { + std::string_view remaining{ groupText }; + while (!remaining.empty()) + { + const auto space = remaining.find(' '); + const auto group = remaining.substr(0, space); + const auto comma = group.find(','); + if (!group.empty()) + { + const std::string host{ "@" + std::string{ group.substr(0, comma) } }; + size_t begin = 0; + while (begin < group.size()) + { + const auto end = group.find(',', begin); + const auto member = group.substr(begin, end == std::string_view::npos ? group.size() - begin : end - begin); + if (!member.empty()) + { + _windowAffinityGroup["@" + std::string{ member }] = host; + } + if (end == std::string_view::npos) + { + break; + } + begin = end + 1; + } + } + if (space == std::string_view::npos) + { + break; + } + remaining.remove_prefix(space + 1); + } + } + } + // tmux control commands are ordered. Issuing refresh only + // after this reply prevents its layout notifications from + // overwriting a surviving multi-window affinity before this + // fresh native client has restored it. + refreshAfterAffinityLoad = true; + } + } + if (refreshAfterAffinityLoad) + { + WriteToLeader("refresh-client -C 80x24"); + return; + } + if (_replyLines.empty()) + return; + // -P -F '#{pane_id}' replies with exactly the new %pane identifier. + const auto id = _replyLines.front(); + if (id.empty() || id.front() != '%') + return; + TmuxFollowerConnection* follower = nullptr; + { + std::lock_guard lock{ _mutex }; + if (_pendingFollowers.empty()) + return; + follower = _pendingFollowers.front().connection; + const auto affinityGroup = _pendingFollowers.front().affinityGroup; + const auto separateNativeWindow = _pendingFollowers.front().separateNativeWindow; + _pendingFollowers.erase(_pendingFollowers.begin()); + _paneAffinityHint[id] = affinityGroup; + if (separateNativeWindow) + { + _separateNativePane.insert(id); + } + } + follower->SetPaneId(id); + std::lock_guard lock{ _mutex }; + _followers[id] = follower; + } + + void TmuxSession::HandleExitSequence() + { + _detaching = true; + _closeFollowerUi(); + _exitTmuxMode(); + _detaching = false; + } + + ITerminalConnection TmuxSession::CreateFollowerForUserSplit(const std::string& sourcePaneId, bool vertical) + { + if (!_leader || sourcePaneId.empty()) + return nullptr; + auto follower = winrt::make_self(this, ""); + { + std::lock_guard lock{ _mutex }; + _pendingFollowers.push_back({ follower.get(), false, false, {} }); + } + WriteToLeader(std::string{ "split-window -P -F '#{pane_id}' -t " } + sourcePaneId + (vertical ? " -h" : " -v")); + return follower.as(); + } + + ITerminalConnection TmuxSession::CreateFollowerForUserTab(std::string_view sourcePaneId) + { + return _createFollowerForUserWindow(sourcePaneId, false); + } + + ITerminalConnection TmuxSession::CreateFollowerForUserWindow(std::string_view sourcePaneId) + { + return _createFollowerForUserWindow(sourcePaneId, true); + } + + ITerminalConnection TmuxSession::_createFollowerForUserWindow(std::string_view sourcePaneId, bool separateNativeWindow) + { + if (!_leader) + return nullptr; + auto follower = winrt::make_self(this, ""); + std::string affinityGroup; + { + std::lock_guard lock{ _mutex }; + _suppressInitialAffinityPublish = false; + if (!separateNativeWindow) + { + // The focused WT pane is synchronous with the user action; + // %session-window-changed can still name the previously + // focused native window when another OS window was raised. + const auto pane = _paneToWindow.find(std::string{ sourcePaneId }); + if (pane != _paneToWindow.end()) + { + const auto group = _windowAffinityGroup.find(pane->second); + affinityGroup = group != _windowAffinityGroup.end() ? group->second : pane->second; + } + if (affinityGroup.empty() && !_activeWindowId.empty()) + { + const auto group = _windowAffinityGroup.find(_activeWindowId); + affinityGroup = group == _windowAffinityGroup.end() ? _activeWindowId : group->second; + } + // Command-line new-tab requests can arrive on the session's + // control page, where no follower is reported as focused. + // They still target the existing native host, so inherit its + // sole group rather than turning the tab into a new host. + if (affinityGroup.empty() && _windowAffinityGroup.size() == 1) + { + affinityGroup = _windowAffinityGroup.begin()->second; + } + if (affinityGroup.empty() && !_paneToWindow.empty()) + { + affinityGroup = _paneToWindow.begin()->second; + } + } + _pendingFollowers.push_back({ follower.get(), true, separateNativeWindow, std::move(affinityGroup) }); + } + WriteToLeader("new-window -P -F '#{pane_id}'"); + return follower.as(); + } + + bool TmuxSession::HandleUserClose(const ITerminalConnection& connection) + { + if (_suppressClosePackets || !IsTmuxConnection(connection)) + return false; + if (const auto follower{ AsTmuxFollower(connection) }) + { + WriteToLeader("kill-pane -t " + follower->PaneId()); + return true; + } + if (const auto leader{ AsTmuxLeader(connection) }) + { + WriteToLeader("kill-pane -t " + leader->PaneId()); + return true; + } + return false; + } + + void TmuxSession::_appendToPane(const std::string& paneId, std::string_view utf8) + { + const std::string data{ utf8 }; + _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, paneId, data]() { + TmuxFollowerConnection* follower = nullptr; + { + std::lock_guard lock{ _mutex }; + if (const auto it = _followers.find(paneId); it != _followers.end()) + { + follower = it->second; + } + } + if (follower) + { + follower->InjectOutput(data); + } + }); + } + + void TmuxSession::_exitTmuxMode() + { + _suppressClosePackets = true; + _commandPrompt = false; + _commandBuffer.clear(); + _homePaneId.clear(); + std::lock_guard lock{ _mutex }; + _followers.clear(); + _pendingFollowers.clear(); + _pendingNativePanes.clear(); + _suppressClosePackets = false; + } + + void TmuxSession::_gatewayPrint(std::string_view text) + { + if (_leader) + { + _leader->InjectOutput(text); + } + } + + void TmuxSession::_logProtocol(std::string_view direction, std::string_view line) + { + if (!_protocolLogging) + { + return; + } + if (line.rfind("%output ", 0) == 0) + { + return; + } + std::string text{ "\r\n" }; + text.append(direction); + text.push_back(' '); + auto visible = line; + if (!visible.empty() && (visible.back() == '\r' || visible.back() == '\n')) + { + visible.remove_suffix(1); + } + text.append(visible); + text.append("\r\n"); + _gatewayPrint(text); + } + + void TmuxSession::_ensureNativePane(const std::string& paneId) + { + if (paneId.empty() || !_page) + { + return; + } + { + std::lock_guard lock{ _mutex }; + if (_followers.contains(paneId) || _pendingNativePanes.contains(paneId)) + { + return; + } + _pendingNativePanes.insert(paneId); + } + _page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [this, paneId]() { + auto releasePending = wil::scope_exit([&]() { + std::lock_guard lock{ _mutex }; + _pendingNativePanes.erase(paneId); + }); + { + std::lock_guard lock{ _mutex }; + if (!_leader || _followers.contains(paneId)) + { + return; + } + } + auto follower = winrt::make_self(this, paneId); + { + std::lock_guard lock{ _mutex }; + _followers[paneId] = follower.get(); + if (_homePaneId.empty()) + { + _homePaneId = paneId; + } + } + OpenFollowerAsWindow(follower.as()); + }); + } + + void TmuxSession::_closeFollowerUi() + { + std::vector followers; + std::vector ids; + std::vector> pages; + { + std::lock_guard lock{ _mutex }; + followers.reserve(_followers.size()); + ids.reserve(_followers.size()); + for (const auto& [id, follower] : _followers) + { + ids.push_back(id); + followers.push_back(follower); + } + // Drop map entries first so late %output cannot re-enter InjectOutput + // after we mark followers closed. + _followers.clear(); + for (const auto& weak : _followerPages) + { + if (auto live = weak.get()) + { + pages.push_back(live); + } + } + _nativeHostPage = nullptr; + _followerPages.clear(); + // The TMUX server remains alive across a client detach/reattach. + // Preserve its pane-to-native-host metadata so the replacement + // followers reconstruct the same OS-window affinity groups. + } + // Native TMUX panes live in other OS windows (RequestNewWindow). Silence + // first, then close on each hosting page (gateway cannot _TmuxFindPane them). + for (auto* follower : followers) + { + if (follower) + { + follower->ForceCloseUi(); + } + } + for (const auto& page : pages) + { + page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [page, ids]() { + for (const auto& id : ids) + { + page->_TmuxClosePane(id); + } + }); + } + } + + void TmuxSession::_syncFollowersToLayout(std::string_view layout, std::string_view windowId) + { + if (_detaching) + { + return; + } + // Drop the optional checksum prefix ("abcd,"). + auto body = layout; + if (body.size() > 5 && body[4] == ',') + { + bool hex = true; + for (size_t i = 0; i < 4; ++i) + { + const char c = body[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) + { + hex = false; + break; + } + } + if (hex) + { + body.remove_prefix(5); + } + } + const auto live = PaneIdsFromTmuxLayout(body); + // A non-empty layout that yields no ids is a parse miss — do not cull + // every follower (that would drop the last pane and block later splits). + if (live.empty()) + { + return; + } + std::unordered_set liveSet(live.begin(), live.end()); + std::vector stale; + std::vector staleFollowers; + { + std::lock_guard lock{ _mutex }; + for (const auto& [id, follower] : _followers) + { + // A %layout-change describes one mux window. Do not close + // panes belonging to other windows merely because they do not + // occur in this window's layout. + const auto mapped = _paneToWindow.find(id); + const bool belongsToLayoutWindow = windowId.empty() || + mapped == _paneToWindow.end() || + mapped->second == windowId; + if (belongsToLayoutWindow && !liveSet.contains(id)) + { + stale.push_back(id); + if (follower) + { + staleFollowers.push_back(follower); + } + } + } + for (const auto& id : stale) + { + _followers.erase(id); + _paneToWindow.erase(id); + if (_homePaneId == id) + { + _homePaneId = live.front(); + } + } + } + // ForceCloseUi + hosting-page _TmuxClosePane tears down TermControls. + // Leaving silenced inert leaves forced the e2e Cmd+W workaround. + for (auto* follower : staleFollowers) + { + follower->ForceCloseUi(); + } + std::vector> pages; + { + std::lock_guard lock{ _mutex }; + for (const auto& weak : _followerPages) + { + if (auto live = weak.get()) + { + pages.push_back(live); + } + } + } + for (const auto& page : pages) + { + page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [page, stale]() { + for (const auto& id : stale) + { + page->_TmuxClosePane(id); + } + }); + } + } + + void TmuxSession::_publishAffinities() + { + // TMUX uses @affinities for the same native-window grouping metadata + // that iTerm2 publishes to tmux. A user New Tab joins its source + // native host; a user New Window starts a distinct native host. + std::unordered_map> groups; + { + std::lock_guard lock{ _mutex }; + if (_loadingPersistedAffinities) + { + return; + } + if (_suppressInitialAffinityPublish) + { + return; + } + groups.reserve(_paneToWindow.size()); + for (const auto& [_, window] : _paneToWindow) + { + if (!window.empty()) + { + const auto group = _windowAffinityGroup.find(window); + const auto& key = group == _windowAffinityGroup.end() ? window : group->second; + auto& members = groups[key]; + if (std::find(members.begin(), members.end(), window) == members.end()) + { + members.push_back(window); + } + } + } + } + if (groups.empty()) + { + return; + } + std::vector> ordered; + ordered.reserve(groups.size()); + for (auto& [_, members] : groups) + { + std::sort(members.begin(), members.end()); + ordered.push_back(std::move(members)); + } + std::sort(ordered.begin(), ordered.end()); + std::string value; + for (const auto& members : ordered) + { + if (!value.empty()) + { + value.push_back(' '); + } + for (size_t i = 0; i < members.size(); ++i) + { + if (i) + { + value.push_back(','); + } + const auto& window = members[i]; + value.append(window.substr(1)); // TMUX stores numeric tmux window ids. + } + } + WriteToLeader("set-option @affinities \"" + value + "\""); + } + + void TmuxSession::_renameWindowTabs(const std::string& windowId, const std::string& name) + { + if (windowId.empty() || name.empty()) + { + return; + } + std::vector paneIds; + std::vector> pages; + { + std::lock_guard lock{ _mutex }; + for (const auto& [paneId, wid] : _paneToWindow) + { + if (wid == windowId) + { + paneIds.push_back(paneId); + } + } + for (const auto& weak : _followerPages) + { + if (auto live = weak.get()) + { + pages.push_back(live); + } + } + } + if (paneIds.empty()) + { + return; + } + const auto title = winrt::hstring{ til::u8u16(name) }; + for (const auto& page : pages) + { + page->Dispatcher().RunAsync(winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, [page, paneIds, title]() { + for (const auto& paneId : paneIds) + { + page->_TmuxSetTabTitleForPane(paneId, title); + } + }); + } + } + + void TmuxSession::_detachCleanly() + { + _detaching = true; + WriteToLeader("detach-client"); + _closeFollowerUi(); + // Drop the leader so a leftover gateway window cannot keep sending + // split-window / send-keys into a dead ConPTY after detach-client. + _leader = nullptr; + _exitTmuxMode(); + } + + void TmuxSession::_forceQuit() + { + _detaching = true; + _closeFollowerUi(); + if (_leader) + { + auto* leader = _leader; + _leader = nullptr; + leader->ForceCloseClient(); + } + _exitTmuxMode(); + _detaching = false; + } + + void TmuxSession::_toggleLogging() + { + _protocolLogging = !_protocolLogging; + _gatewayPrint(_protocolLogging ? "\r\ntmux logging enabled\r\n" : "\r\ntmux logging disabled\r\n"); + } + + void TmuxSession::_beginCommandPrompt() + { + _commandPrompt = true; + _commandBuffer.clear(); + _gatewayPrint("\r\nEnter a tmux command: "); + } + + void TmuxSession::_handleCommandPromptKey(char ch) + { + if (ch == '\r' || ch == '\n') + { + _commandPrompt = false; + _gatewayPrint("\r\n"); + const auto command = std::move(_commandBuffer); + _commandBuffer.clear(); + if (!command.empty()) + { + constexpr std::string_view selectPrefix{ "select-window -t @" }; + if (command.rfind(selectPrefix, 0) == 0) + { + const auto end = command.find_first_of(" \t", selectPrefix.size()); + std::lock_guard lock{ _mutex }; + _activeWindowId = command.substr(selectPrefix.size(), end - selectPrefix.size()); + _activeWindowId.insert(_activeWindowId.begin(), '@'); + } + WriteToLeader(command); + } + return; + } + if (ch == '\x7f' || ch == '\b') + { + if (!_commandBuffer.empty()) + { + _commandBuffer.pop_back(); + _gatewayPrint("\b \b"); + } + return; + } + if (ch >= 32 && ch < 127) + { + _commandBuffer.push_back(ch); + _gatewayPrint(std::string(1, ch)); + } + } + + void TmuxSession::HandleLeaderInput(std::string_view keys) + { + for (unsigned char ch : keys) + { + if (_commandPrompt) + { + if (ch == 0x1b) + { + _commandPrompt = false; + _commandBuffer.clear(); + _gatewayPrint("\r\n"); + continue; + } + _handleCommandPromptKey(static_cast(ch)); + continue; + } + if (ch == 0x1b) + { + _detachCleanly(); + } + else if (ch == 'x' || ch == 'X') + { + _forceQuit(); + } + else if (ch == 'l' || ch == 'L') + { + _toggleLogging(); + } + else if (ch == 'c' || ch == 'C') + { + _beginCommandPrompt(); + } + } + } +} diff --git a/src/cascadia/TerminalApp/HtmSession.h b/src/cascadia/TerminalApp/TmuxSession.h similarity index 52% rename from src/cascadia/TerminalApp/HtmSession.h rename to src/cascadia/TerminalApp/TmuxSession.h index f6a49ae6701..6156993fa1d 100644 --- a/src/cascadia/TerminalApp/HtmSession.h +++ b/src/cascadia/TerminalApp/TmuxSession.h @@ -3,7 +3,7 @@ #pragma once -#include "HtmConnections.h" +#include "TmuxConnections.h" #include #include @@ -14,19 +14,19 @@ namespace winrt::TerminalApp::implementation { struct TerminalPage; - class HtmSession + class TmuxSession { public: - explicit HtmSession(TerminalPage* page); + explicit TmuxSession(TerminalPage* page); - void AttachLeader(HtmLeaderConnection* leader); - void DetachLeader(HtmLeaderConnection* leader); + void AttachLeader(TmuxLeaderConnection* leader); + void DetachLeader(TmuxLeaderConnection* leader); bool IsActive() const noexcept; - bool IsHtmConnection(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection) const; + bool IsTmuxConnection(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection) const; std::string LeaderPaneId() const; - void RegisterFollower(HtmFollowerConnection* follower); - void UnregisterFollower(HtmFollowerConnection* follower); + void RegisterFollower(TmuxFollowerConnection* follower); + void UnregisterFollower(TmuxFollowerConnection* follower); bool HasFollower(const std::string& paneId) const; std::string FirstLiveFollowerPaneId() const; @@ -37,16 +37,18 @@ namespace winrt::TerminalApp::implementation void SendKeys(std::string_view paneId, std::string_view utf8); winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection CreateFollowerForUserSplit(const std::string& sourcePaneId, bool vertical); - winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection CreateFollowerForUserTab(); + winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection CreateFollowerForUserTab(std::string_view sourcePaneId = {}); + winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection CreateFollowerForUserWindow(std::string_view sourcePaneId = {}); bool HandleUserClose(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& connection); - // First native HTM pane opens an OS window; later panes become tabs on + // First native TMUX pane opens an OS window; later panes become tabs on // that host (WT-native), instead of one OS window per tmux window. void SetNativeHostPage(TerminalPage* page) noexcept; TerminalPage* NativeHostPage() const noexcept; void ClearNativeHostPage(TerminalPage* page) noexcept; void RegisterFollowerPage(TerminalPage* page) noexcept; - // WT new-tab → tab on the native HTM host (first one opens an OS window). + void SetFollowerAffinityHost(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& follower, std::string_view sourcePaneId); + // WT new-tab → tab on the native TMUX host (first one opens an OS window). void OpenFollowerAsTab(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& follower); // WT new-window / server new-window → always a new OS window. void OpenFollowerAsWindow(const winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection& follower); @@ -54,38 +56,59 @@ namespace winrt::TerminalApp::implementation private: struct PendingFollower { - HtmFollowerConnection* connection; + TmuxFollowerConnection* connection; bool isTab; + bool separateNativeWindow; + std::string affinityGroup; }; void _appendToPane(const std::string& paneId, std::string_view utf8); - void _exitHtmMode(); + void _exitTmuxMode(); void _finishReply(); void _gatewayPrint(std::string_view text); void _logProtocol(std::string_view direction, std::string_view line); void _ensureNativePane(const std::string& paneId); void _closeFollowerUi(); - void _syncFollowersToLayout(std::string_view layout); + void _syncFollowersToLayout(std::string_view layout, std::string_view windowId); + void _publishAffinities(); void _renameWindowTabs(const std::string& windowId, const std::string& name); void _detachCleanly(); void _forceQuit(); void _toggleLogging(); void _beginCommandPrompt(); void _handleCommandPromptKey(char ch); + winrt::Microsoft::Terminal::TerminalConnection::ITerminalConnection _createFollowerForUserWindow(std::string_view sourcePaneId, bool separateNativeWindow); TerminalPage* _page; winrt::weak_ref _nativeHostPage; std::vector> _followerPages; std::unordered_map _paneToWindow; // "%0" -> "@1" - HtmLeaderConnection* _leader{ nullptr }; - mutable std::mutex _mutex; - std::unordered_map _followers; + std::unordered_map _windowAffinityGroup; // "@1" -> native host key + std::unordered_map _paneAffinityHint; + std::unordered_set _separateNativePane; + TmuxLeaderConnection* _leader{ nullptr }; + mutable std::recursive_mutex _mutex; + std::unordered_map _followers; std::unordered_set _pendingNativePanes; std::vector _pendingFollowers; std::vector _replyLines; std::string _commandBuffer; std::string _homePaneId; + std::string _activeWindowId; bool _inReply{ false }; + // A fresh Windows Terminal client can reattach to a daemon that has + // retained the iTerm2-compatible affinity setting. Do not replace it + // with a transient one-window-per-pane reconstruction until we have + // read that setting. + bool _loadingPersistedAffinities{ false }; + // One command reply may already be in flight when the control-mode + // handshake queues the @affinities query. + bool _skipPreAffinityReply{ false }; + // Initial refresh-client notifications describe panes but not native + // window ownership. Keep the daemon's existing affinity value intact + // until an explicit Windows Terminal tab/window action establishes a + // new ownership decision. + bool _suppressInitialAffinityPublish{ true }; bool _suppressClosePackets{ false }; bool _protocolLogging{ false }; bool _commandPrompt{ false }; diff --git a/src/cascadia/WindowsTerminal/WindowEmperor.cpp b/src/cascadia/WindowsTerminal/WindowEmperor.cpp index 79a0ff1b871..8a794cd0faa 100644 --- a/src/cascadia/WindowsTerminal/WindowEmperor.cpp +++ b/src/cascadia/WindowsTerminal/WindowEmperor.cpp @@ -861,13 +861,13 @@ void WindowEmperor::_dispatchCommandline(winrt::TerminalApp::CommandlineArgs arg } } - if (window) - { - window->DispatchCommandline(std::move(args)); - } - else - { - _createWindowMaybeRestoringWorkspace(windowId, windowName, std::move(args)); + if (window) + { + window->DispatchCommandline(std::move(args)); + } + else + { + _createWindowMaybeRestoringWorkspace(windowId, windowName, std::move(args)); } } @@ -912,7 +912,12 @@ safe_void_coroutine WindowEmperor::_dispatchCommandlineCurrentDesktop(winrt::Ter window = _mostRecentWindow(); } - if (window) + if (!window) + { + window = _mostRecentWindow(); + } + + if (window) { window->DispatchCommandline(std::move(args)); } diff --git a/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj b/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj index 2212bec3773..98b5e3deacd 100644 --- a/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj +++ b/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj @@ -23,7 +23,7 @@ - + Create diff --git a/src/cascadia/ut_app/HtmProtocolTests.cpp b/src/cascadia/ut_app/TmuxProtocolTests.cpp similarity index 90% rename from src/cascadia/ut_app/HtmProtocolTests.cpp rename to src/cascadia/ut_app/TmuxProtocolTests.cpp index 4b7619b6eb2..85a5f84322e 100644 --- a/src/cascadia/ut_app/HtmProtocolTests.cpp +++ b/src/cascadia/ut_app/TmuxProtocolTests.cpp @@ -2,7 +2,7 @@ // Licensed under the MIT license. #include "precomp.h" -#include "../TerminalApp/HtmProtocol.h" +#include "../TerminalApp/TmuxProtocol.h" #include #include #include @@ -14,13 +14,13 @@ using namespace WEX::Logging; using namespace WEX::TestExecution; using namespace WEX::Common; -using namespace Microsoft::Terminal::Htm; +using namespace Microsoft::Terminal::Tmux; namespace TerminalAppUnitTests { - class HtmProtocolTests + class TmuxProtocolTests { - TEST_CLASS(HtmProtocolTests); + TEST_CLASS(TmuxProtocolTests); TEST_METHOD(EncodeLengthRoundTrip) { @@ -72,24 +72,24 @@ namespace TerminalAppUnitTests VERIFY_ARE_EQUAL("REST", second.remainder); } - TEST_METHOD(ConPtyHtmCarrierRoundTrip) + TEST_METHOD(ConPtyTmuxCarrierRoundTrip) { const std::string payload{ TmuxControlDcs }; - const auto encoded = EncodeConPtyHtmCarrier(payload); + const auto encoded = EncodeConPtyTmuxCarrier(payload); VERIFY_IS_TRUE(encoded.find("\x1b[?777;") == 0); - const auto decoded = DecodeConPtyHtmCarrier("", encoded); + const auto decoded = DecodeConPtyTmuxCarrier("", encoded); VERIFY_ARE_EQUAL(payload, decoded.decoded); VERIFY_IS_TRUE(decoded.pending.empty()); } - TEST_METHOD(ConPtyHtmCarrierSplitAcrossChunks) + TEST_METHOD(ConPtyTmuxCarrierSplitAcrossChunks) { - const auto encoded = EncodeConPtyHtmCarrier("ab"); + const auto encoded = EncodeConPtyTmuxCarrier("ab"); const auto cut = encoded.size() / 2; - const auto first = DecodeConPtyHtmCarrier("", encoded.substr(0, cut)); + const auto first = DecodeConPtyTmuxCarrier("", encoded.substr(0, cut)); VERIFY_IS_TRUE(first.decoded.empty()); VERIFY_IS_FALSE(first.pending.empty()); - const auto second = DecodeConPtyHtmCarrier(first.pending, encoded.substr(cut)); + const auto second = DecodeConPtyTmuxCarrier(first.pending, encoded.substr(cut)); VERIFY_ARE_EQUAL("ab", second.decoded); VERIFY_IS_TRUE(second.pending.empty()); } @@ -108,7 +108,7 @@ namespace TerminalAppUnitTests // U+1F600 😀 arrives as two KEYEVENTF_UNICODE units (D83D DE00). Win32InputDecodeState state; VERIFY_ARE_EQUAL("", DecodeWin32InputMode("\x1b[0;0;55357;1;0;1_", state)); - VERIFY_ARE_EQUAL(u8"\U0001F600", DecodeWin32InputMode("\x1b[0;0;56832;1;0;1_", state)); + VERIFY_ARE_EQUAL("\xF0\x9F\x98\x80", DecodeWin32InputMode("\x1b[0;0;56832;1;0;1_", state)); VERIFY_ARE_EQUAL(static_cast(0), state.pendingHigh); } @@ -146,17 +146,17 @@ namespace TerminalAppUnitTests } // This stress test is NOT headless: it spawns a real htmd daemon - // indirectly by launching htm.exe (which is exactly how Windows Terminal + // indirectly by launching the EternalTerminal client (which is exactly how Windows Terminal // does it). The test then drives several tabs/panes and does concurrent // read/write on all of them to expose framing races and clean-exit bugs. TEST_METHOD(ConcurrentTabsPanesStressReadWrite) { // ------------------------------------------------------------------ - // 1) Locate htm.exe / htmd.exe – built by EternalTerminal. + // 1) Locate EternalTerminal client / daemon binaries – built by EternalTerminal. // We probe HTM_BIN_DIR, then common build outputs. If not found // we skip rather than fail so CI without ET checkout still passes. // ------------------------------------------------------------------ - auto findHtmBinary = [](const wchar_t* name) -> std::wstring { + auto findTmuxBinary = [](const wchar_t* name) -> std::wstring { wchar_t* dup = nullptr; size_t len = 0; if (_wdupenv_s(&dup, &len, L"HTM_BIN_DIR") == 0 && dup && *dup) @@ -198,14 +198,14 @@ namespace TerminalAppUnitTests return L""; }; - const auto htmPath = findHtmBinary(L"htm.exe"); - const auto htmdPath = findHtmBinary(L"htmd.exe"); - if (htmPath.empty() || htmdPath.empty()) + const auto tmuxPath = findTmuxBinary(L"htm.exe"); + const auto htmdPath = findTmuxBinary(L"htmd.exe"); + if (tmuxPath.empty() || htmdPath.empty()) { - Log::Comment(L"htm/htmd not found – skipping live-daemon stress (build EternalTerminal first)"); + Log::Comment(L"tmux/htmd not found – skipping live-daemon stress (build EternalTerminal first)"); return; } - Log::Comment(NoThrowString().Format(L"Using htm=%s htmd=%s", htmPath.c_str(), htmdPath.c_str())); + Log::Comment(NoThrowString().Format(L"Using tmux=%s htmd=%s", tmuxPath.c_str(), htmdPath.c_str())); // ------------------------------------------------------------------ // 2) Isolated TEMP for AF_UNIX socket: Windows htmd uses @@ -283,9 +283,9 @@ namespace TerminalAppUnitTests } // ------------------------------------------------------------------ - // 3) Spawn htmd INDIRECTLY by launching htm.exe -x with anonymous pipes. + // 3) Spawn htmd INDIRECTLY by launching the EternalTerminal client with -x. // This is exactly how TerminalPage does it: the leader ConPTY runs - // htm, htm daemonizes htmd on demand. + // tmux, tmux daemonizes htmd on demand. // ------------------------------------------------------------------ SECURITY_ATTRIBUTES sa{ sizeof(sa), nullptr, TRUE }; HANDLE hStdinRd{}, hStdinWr{}, hStdoutRd{}, hStdoutWr{}; @@ -300,14 +300,14 @@ namespace TerminalAppUnitTests si.hStdOutput = hStdoutWr; si.hStdError = hStdoutWr; PROCESS_INFORMATION pi{}; - std::wstring cmd = L"\"" + htmPath + L"\" -x"; + std::wstring cmd = L"\"" + tmuxPath + L"\" -x"; // Mutable buffer for CreateProcess std::vector cmdBuf(cmd.begin(), cmd.end()); cmdBuf.push_back(L'\0'); // Environment block with TEMP/TMP/HTM_BIN_DIR pointing at isolated dir // Build a tiny env: copy current + override. - std::wstring envExtra = L"TEMP=" + std::wstring(tmpDir) + L"\0TMP=" + std::wstring(tmpDir) + L"\0HTM_BIN_DIR=" + std::filesystem::path(htmPath).parent_path().wstring() + L"\0"; + std::wstring envExtra = L"TEMP=" + std::wstring(tmpDir) + L"\0TMP=" + std::wstring(tmpDir) + L"\0HTM_BIN_DIR=" + std::filesystem::path(tmuxPath).parent_path().wstring() + L"\0"; // We'll just set process env for child via SetEnvironmentVariable before CreateProcess // and restore after – simpler than building full block. wchar_t oldTemp[MAX_PATH]{}, oldTmp[MAX_PATH]{}; @@ -338,7 +338,7 @@ namespace TerminalAppUnitTests if (pi.hThread) CloseHandle(pi.hThread); }); - VERIFY_IS_TRUE(ok, NoThrowString().Format(L"CreateProcess htm -x failed %d", GetLastError())); + VERIFY_IS_TRUE(ok, NoThrowString().Format(L"CreateProcess tmux -x failed %d", GetLastError())); // Child no longer needs write end of stdout / read end of stdin CloseHandle(hStdoutWr); @@ -346,7 +346,7 @@ namespace TerminalAppUnitTests CloseHandle(hStdinRd); hStdinRd = nullptr; - // Helper: peek + read like HtmPipeSession + // Helper: peek + read like TmuxPipeSession auto peekAvail = [&](HANDLE h) -> DWORD { DWORD avail = 0; PeekNamedPipe(h, nullptr, 0, nullptr, &avail, nullptr); @@ -354,7 +354,7 @@ namespace TerminalAppUnitTests }; auto writePacket = [&](HANDLE h, const std::string& pkt) { DWORD written = 0; - // Like HtmLeaderConnection::WriteRaw – one WriteFile per packet + // Like TmuxLeaderConnection::WriteRaw – one WriteFile per packet // so concurrent writers cannot splice. WriteFile(h, pkt.data(), (DWORD)pkt.size(), &written, nullptr); }; @@ -406,7 +406,7 @@ namespace TerminalAppUnitTests return; std::string readBuf; - std::string htmBuffer; + std::string tmuxBuffer; std::vector packets; std::string initJson; auto pump = [&](DWORD timeoutMs) { @@ -426,16 +426,16 @@ namespace TerminalAppUnitTests if (readBuf.find("\x1b[###q") != std::string::npos) { size_t pos = readBuf.find("\x1b[###q"); - htmBuffer.append(readBuf.substr(pos + 6)); + tmuxBuffer.append(readBuf.substr(pos + 6)); readBuf.clear(); - auto res = ParsePackets(htmBuffer); + auto res = ParsePackets(tmuxBuffer); for (auto& p : res.first) { if (p.header == InitState && initJson.empty()) initJson = p.payload; packets.push_back(std::move(p)); } - htmBuffer = std::move(res.second); + tmuxBuffer = std::move(res.second); if (!initJson.empty()) return true; } @@ -452,7 +452,7 @@ namespace TerminalAppUnitTests }; // Wait for INIT_STATE (daemon handshake) - VERIFY_IS_TRUE(pump(15000), L"did not receive INIT_STATE from htm/htmd"); + VERIFY_IS_TRUE(pump(15000), L"did not receive INIT_STATE from tmux/htmd"); Log::Comment(NoThrowString().Format(L"INIT json %hs", initJson.c_str())); // Extract first pane ID from JSON (simple scan for 36-char uuid) @@ -485,7 +485,7 @@ namespace TerminalAppUnitTests VERIFY_IS_TRUE(p0.size() == 36, NoThrowString().Format(L"first pane %hs", p0.c_str())); // ------------------------------------------------------------------ - // 4) Create several tabs/panes via HTM framing – like TerminalApp + // 4) Create several tabs/panes via TMUX framing – like TerminalApp // does when applying INIT_STATE splits. Use real daemon. // ------------------------------------------------------------------ auto makeId = []() -> std::string { @@ -536,11 +536,11 @@ namespace TerminalAppUnitTests ReadFile(hStdoutRd, tmp, std::min(avail, sizeof(tmp)), &got, nullptr); if (got) { - htmBuffer.append(tmp, got); - auto res = ParsePackets(htmBuffer); + tmuxBuffer.append(tmp, got); + auto res = ParsePackets(tmuxBuffer); for (auto& p : res.first) packets.push_back(std::move(p)); - htmBuffer = std::move(res.second); + tmuxBuffer = std::move(res.second); } } } @@ -548,7 +548,7 @@ namespace TerminalAppUnitTests // ------------------------------------------------------------------ // 5) Concurrent I/O stress: 4 writers × 60 keys × 6 panes + resizes, // all through the single leader pipe protected by a mutex like - // HtmLeaderConnection::_writeMutex. Concurrent readers drain stdout. + // TmuxLeaderConnection::_writeMutex. Concurrent readers drain stdout. // ------------------------------------------------------------------ std::mutex writeMtx; auto writeLocked = [&](const std::string& pkt) { @@ -601,8 +601,8 @@ namespace TerminalAppUnitTests if (ReadFile(hStdoutRd, tmp, sizeof(tmp), &got, nullptr) && got) { std::lock_guard lk{ outMtx }; - htmBuffer.append(tmp, got); - auto res = ParsePackets(htmBuffer); + tmuxBuffer.append(tmp, got); + auto res = ParsePackets(tmuxBuffer); for (auto& p : res.first) { if (p.header == AppendToPane && p.payload.size() >= 36) @@ -613,7 +613,7 @@ namespace TerminalAppUnitTests collectedOutput.append(dec); } } - htmBuffer = std::move(res.second); + tmuxBuffer = std::move(res.second); } } else @@ -640,8 +640,8 @@ namespace TerminalAppUnitTests ReadFile(hStdoutRd, tmp, sizeof(tmp), &got, nullptr); if (got) { - htmBuffer.append(tmp, got); - auto res = ParsePackets(htmBuffer); + tmuxBuffer.append(tmp, got); + auto res = ParsePackets(tmuxBuffer); for (auto& p : res.first) { if (p.header == AppendToPane && p.payload.size() >= 36) @@ -650,7 +650,7 @@ namespace TerminalAppUnitTests collectedOutput.append(Base64Decode(b64)); } } - htmBuffer = std::move(res.second); + tmuxBuffer = std::move(res.second); } } } @@ -676,19 +676,19 @@ namespace TerminalAppUnitTests writeLocked(pkt); } // Wait for daemon exit (htmd) – poll by trying to connect or by - // checking that htm process exits after daemon closes pipe + // checking that tmux process exits after daemon closes pipe for (int i = 0; i < 50; ++i) { if (WaitForSingleObject(pi.hProcess, 0) == WAIT_OBJECT_0) break; Sleep(100); } - // htm should have exited after htmd closed SESSION_END - bool htmExited = (WaitForSingleObject(pi.hProcess, 0) == WAIT_OBJECT_0); - Log::Comment(NoThrowString().Format(L"htm exited=%d collected %d bytes", (int)htmExited, (int)collectedOutput.size())); - VERIFY_IS_TRUE(htmExited, L"htm should exit cleanly after daemon 'x' shutdown"); + // tmux should have exited after htmd closed SESSION_END + bool tmuxExited = (WaitForSingleObject(pi.hProcess, 0) == WAIT_OBJECT_0); + Log::Comment(NoThrowString().Format(L"tmux exited=%d collected %d bytes", (int)tmuxExited, (int)collectedOutput.size())); + VERIFY_IS_TRUE(tmuxExited, L"tmux should exit cleanly after daemon 'x' shutdown"); - // Verify IPC file removed (tmpDir\htm..ipc) - poll because htmd unlinks asynchronously after SESSION_END + // Verify IPC file removed (tmpDir\tmux..ipc) - poll because htmd unlinks asynchronously after SESSION_END { bool ipcExists = false; for (int i = 0; i < 50; ++i) diff --git a/src/common.build.pre.props b/src/common.build.pre.props index b726c916f19..7c4d6070fe9 100644 --- a/src/common.build.pre.props +++ b/src/common.build.pre.props @@ -181,6 +181,16 @@ MachineX86 + + + MachineX64 + + + + + MachineARM64 + + diff --git a/src/features.xml b/src/features.xml index 86934e907aa..153102179ee 100644 --- a/src/features.xml +++ b/src/features.xml @@ -188,8 +188,8 @@ - Feature_HtmIntegration - Detect HTM init sequences and map Windows Terminal tabs/panes onto htmd + Feature_TmuxIntegration + Detect TMUX init sequences and map Windows Terminal tabs/panes onto htmd AlwaysEnabled From cc7d249e030296d949bfca0e04cc76b4ab90be1a Mon Sep 17 00:00:00 2001 From: Jason Gauci Date: Wed, 16 Sep 2026 01:08:23 -0500 Subject: [PATCH 8/9] Rewrite HTM integration spec for tmux control mode Rename the integration specification to tmux-integration.md and replace the obsolete private HTM framing design with the current tmux -CC architecture. Document gateway activation, DCS and ConPTY carrier handling, tmux control notifications and commands, leader and follower connections, native window affinities, attach ordering, race avoidance, and clean teardown. Keep htm, htmd, and HTM_BIN_DIR only where they identify EternalTerminal binaries or configuration. Update the Feature_TmuxIntegration description to use tmux terminology and accurately describe the native window, tab, and pane mapping. --- doc/specs/htm-integration.md | 139 --------------------- doc/specs/tmux-integration.md | 223 ++++++++++++++++++++++++++++++++++ src/features.xml | 2 +- 3 files changed, 224 insertions(+), 140 deletions(-) delete mode 100644 doc/specs/htm-integration.md create mode 100644 doc/specs/tmux-integration.md diff --git a/doc/specs/htm-integration.md b/doc/specs/htm-integration.md deleted file mode 100644 index 47b004c49fb..00000000000 --- a/doc/specs/htm-integration.md +++ /dev/null @@ -1,139 +0,0 @@ ---- -author: MisterTea -created on: 2026-08-30 -last updated: 2026-08-30 -issue id: n/a ---- - -# HTM (headless terminal multiplexer) integration - -## Abstract - -This spec describes how Windows Terminal detects an EternalTerminal `htm` session on an existing ConPTY connection, takes over tabs/panes so they map onto `htmd`, and tears the session down without a new `connectionType`. The work is gated by `Feature_HtmIntegration` (enabled in Dev, disabled in Release and WindowsInbox). - -## Inspiration - -[hyper-htm](https://github.com/MisterTea/hyper-htm) wraps Hyper so that running `htm` in a tab steals that PTY, creates follower panes with no local PTY, and maps split/new-tab/close onto the `htmd` daemon. Windows Terminal has no Hyper-style plugin API: JSON fragments can inject profiles and color schemes only. Shell integration (OSC 133) and `ShellExtension` do not intercept splits. Matching that UX requires TerminalApp to wrap ConPTY, the same pattern as `DebugTapConnection`. - -EternalTerminal `htm`/`htmd` on Windows uses ConPTY for pane shells and AF_UNIX IPC at `%TEMP%\htm..ipc`. The wire protocol is unchanged so hyper-htm and Windows Terminal stay compatible. - -## Solution Design - -``` - Windows Terminal EternalTerminal - ┌─────────────────────────────┐ ┌──────────────────────┐ - │ Leader pane │ PTY │ htm.exe byte bridge │ - │ ConPTY + HtmLeaderConnection ────────►│ │ │ - │ Follower panes │ framed │ ▼ AF_UNIX │ - │ HtmFollowerConnection │ packets │ htmd.exe mux daemon │ - │ HtmSession (per window) │ │ ConPTY per pane │ - └─────────────────────────────┘ └──────────────────────┘ -``` - -### Why wrap ConPTY instead of a new `connectionType` - -Users type `htm` in an existing profile (PowerShell, cmd, WSL). A dedicated `connectionType` would require a separate profile and would not take over a tab that is already running. Wrapping every ConPTY in `HtmLeaderConnection` (behind the feature flag) matches Hyper: pass-through until `ESC[###q`, then consume framed packets. - -### Wire protocol - -Compatible with EternalTerminal `HtmHeaderCodes.hpp` and `hyper-htm/htm-core.js`. - -- Init: `ESC[###q` (pass through bytes before this; hold a partial match across chunks) -- Exit: `ESC[$$$q` (leave HTM mode; leader shows a normal shell again) -- Frame: `[1-byte header][8-char base64 of little-endian int32 length][payload]` -- `SESSION_END` (`D`) is a single byte with no length field - -| Header | Payload | Direction | -|--------|---------|-----------| -| `1` INSERT_KEYS | 36-char pane UUID + base64 UTF-8 keys | client → server | -| `2` INIT_STATE | JSON multiplexer state | server → client | -| `3` CLIENT_CLOSE_PANE | pane UUID | client → server | -| `4` APPEND_TO_PANE | pane UUID + base64 output | server → client | -| `5` NEW_TAB | tab UUID + pane UUID | client → server | -| `8` SERVER_CLOSE_PANE | pane UUID | server → client | -| `9` NEW_SPLIT | source UUID + pane UUID + `'1'` vertical / `'0'` horizontal | client → server | -| `A` RESIZE_PANE | base64 int32 cols + base64 int32 rows + pane UUID | client → server | -| `B` DEBUG_LOG | base64 text | server → client | -| `C` INSERT_DEBUG_KEYS | raw keys (leader keystrokes, Escape disconnect) | client → server | -| `D` SESSION_END | none | either | - -UUIDs are 36-character `GuidToPlainString` values (no braces). HTM `'1'` is a vertical divider (Windows Terminal left/right); `'0'` is horizontal (up/down). - -### Types (`src/cascadia/TerminalApp/`) - -| Type | Role | -|------|------| -| `HtmProtocol` | Framing, CSI consume, packet parse | -| `HtmLeaderConnection` | Wraps ConPTY; pass-through until init CSI; then consume packets and route leader keys as `INSERT_DEBUG_KEYS` | -| `HtmFollowerConnection` | No process. `WriteInput` → `INSERT_KEYS`; `Resize` → `RESIZE_PANE`; `Close` → `CLIENT_CLOSE_PANE` | -| `HtmSession` | Per-window on `TerminalPage`: UUID map, INIT_STATE layout, user split/tab intercept | - -On `INIT_STATE`, the first pane of the first tab maps onto the existing leader (no second ConPTY). Remaining panes are created with `HtmFollowerConnection` via sequential binary splits (HTM n-way splits). `APPEND_TO_PANE` / `DEBUG_LOG` are injected into the mapped `TermControl`s. - -While a session is active, split and new-tab on an HTM pane create a follower and send `NEW_SPLIT` / `NEW_TAB` instead of spawning ConPTY. Closing a follower sends `CLIENT_CLOSE_PANE`. Closing the leader or seeing `SESSION_END` / `ESC[$$$q` tears down followers and returns the leader to a normal shell; the Windows Terminal window stays open (Hyper closes the window; Windows Terminal only ends the HTM session). - -### Settings - -Put `htm.exe` / `htmd.exe` on `PATH`, or set a profile environment variable so a local EternalTerminal build is found: - -```json -{ - "profiles": { - "defaults": { - "environment": { - "HTM_BIN_DIR": "C:\\path\\to\\et\\build" - } - } - } -} -``` - -When `HTM_BIN_DIR` is set, Terminal prepends it to `PATH` for that ConPTY. An optional profile `"commandline": "htm.exe"` is not required for takeover. - -## UI/UX Design - -1. Open Windows Terminal, run `htm` in any ConPTY profile. -2. Extra panes/tabs appear matching the multiplexer state (including restored scrollback). -3. Typing in a follower is injected into `htmd`; output streams back as `APPEND_TO_PANE`. -4. Split / new tab from an HTM pane create remote panes, not local shells. -5. Escape (via `htm` debug keys) or `ESC[$$$q` leaves HTM mode; `htm` again restores the session. - -## Capabilities - -### Accessibility - -Follower panes are normal `TermControl` instances. Screen readers see the same text buffer as any other pane. No new UI chrome. - -### Security - -`htm`/`htmd` already run as the current user. AF_UNIX IPC is per-user under `%TEMP%`. This feature only interprets bytes already produced by a user-started process. `HTM_BIN_DIR` is an explicit profile setting. - -### Reliability - -Malformed frames drop HTM mode instead of wedging the connection. Leader close disconnects the session without closing the whole window. Unknown HTM headers cause `htmd` to disconnect that client. - -### Compatibility - -Disabled in Release and WindowsInbox via `Feature_HtmIntegration`. Dev builds wrap ConPTY; until `htm` prints `ESC[###q`, behavior is unchanged. The wire protocol is not versioned independently of EternalTerminal / hyper-htm. - -### Performance, Power, and Efficiency - -Pass-through copies ConPTY output until init. After takeover, framed packets replace raw PTY traffic for followers (no extra processes). Overhead is comparable to `DebugTapConnection`. - -## Potential Issues - -- This environment cannot compile Windows Terminal; first verification needs Windows 10 2004+ and Visual Studio. -- JSON fragment extensions cannot provide this behavior; an upstream plugin API (GH#4000) would be a larger design. -- Undo-close of an HTM follower may recreate a local ConPTY instead of a follower. -- n-way HTM splits are approximated with sequential 50/50 binary splits. - -## Future considerations - -A first-class `connectionType` or connection-wrapper extension point would let this live out-of-tree. Until then, a feature-flagged branch is the reviewable shape for an upstream PR. - -## Resources - -- EternalTerminal `src/htm/` (`HtmHeaderCodes.hpp`, `HtmClient`, `HtmServer`, `TerminalHandler`) -- [hyper-htm](https://github.com/MisterTea/hyper-htm) `htm-core.js`, `index.js` -- `DebugTapConnection` in TerminalApp -- Windows Terminal GH#4000 (extensibility) diff --git a/doc/specs/tmux-integration.md b/doc/specs/tmux-integration.md new file mode 100644 index 00000000000..b9ed1ebcc5c --- /dev/null +++ b/doc/specs/tmux-integration.md @@ -0,0 +1,223 @@ +--- +author: MisterTea +created on: 2026-08-30 +last updated: 2026-09-16 +issue id: n/a +--- + +# tmux control-mode integration + +## Abstract + +This spec describes how Windows Terminal acts as a native UI for a tmux control-mode session. A gateway process runs on an existing ConPTY connection, while tmux windows and panes are represented by Windows Terminal windows, tabs, and panes. Terminal sends ordinary tmux commands and consumes ordinary newline-delimited tmux control-mode notifications; it does not define a separate HTM multiplexer protocol. + +The Windows implementation is currently provided by EternalTerminal. Its executables retain the names `htm` and `htmd`, but `htm` exposes the same control protocol as `tmux -CC`. References to `htm` or `htmd` in this document therefore identify those programs or their Windows transport details, not a distinct Terminal-side protocol. + +The work is gated by `Feature_TmuxIntegration`. It is enabled in non-Inbox builds and disabled for WindowsInbox branding. + +## Background + +iTerm2 introduced a native tmux integration based on `tmux -CC`. In control mode, the terminal is a tmux client: it receives structured notifications about output, layouts, and lifecycle events, and it sends tmux commands for input and UI operations. WezTerm implements the same model. + +EternalTerminal supplies a Windows tmux-compatible client and daemon through `htm.exe` and `htmd.exe`. On Windows, `htmd` owns the pane ConPTY processes and exposes tmux control mode to `htm`. Windows Terminal provides the native renderer and maps its UI actions back to tmux commands. + +Windows Terminal has no Hyper-style plugin API. JSON fragments can add profiles and color schemes, but they cannot intercept split, tab, window, resize, or close operations. The integration therefore lives in TerminalApp and wraps the gateway ConPTY connection, following the same broad connection-wrapper pattern as `DebugTapConnection`. + +## Solution design + +```text + Windows Terminal EternalTerminal + ┌──────────────────────────────────┐ ┌──────────────────────┐ + │ Gateway tab │ ConPTY │ htm.exe │ + │ TmuxLeaderConnection ───────────────────► │ tmux control client │ + │ │ │ │ │ │ + │ │ commands/notifications│ │ ▼ AF_UNIX │ + │ ▼ │ │ htmd.exe mux server │ + │ TmuxSession │ │ ConPTY per pane │ + │ TmuxFollowerConnection per %pane └──────────────────────┘ + │ native windows/tabs/panes │ + └──────────────────────────────────┘ +``` + +The gateway tab is the control plane. It displays a small command menu and does not directly render a tmux pane. Each real tmux pane is rendered by a `TmuxFollowerConnection`, which has no local child process of its own. + +### Activation + +Terminal wraps a ConPTY in `TmuxLeaderConnection` when all of the following are true: + +- `Feature_TmuxIntegration` is enabled. +- The connection is a `ConptyConnection`. +- The configured command line launches `htm` or `htm.exe`. + +This is established when the connection is created. Typing `htm` later inside an arbitrary shell tab does not retrofit that existing connection with the wrapper. + +No new `connectionType` is required. The profile remains a normal local profile and the wrapper passes output through unchanged until it sees the control-mode start marker. + +### Control-mode transport + +The logical protocol is tmux control mode: + +- Entry marker: DCS `1000p` (`ESC P 1000 p`). +- Exit marker: ST (`ESC \\`). +- Records between the markers are newline-delimited tmux control-mode records. +- Commands sent to the gateway are ordinary tmux commands terminated by carriage return. + +ConPTY strips DCS sequences. To preserve arbitrary control bytes on Windows, EternalTerminal encodes them as one or more private CSI carrier sequences: + +```text +ESC [ ? 777 ; ; ... q +``` + +Each carrier holds at most 15 decimal byte values. `TmuxLeaderConnection` incrementally decodes carriers, including carriers split across output chunks, before looking for the DCS/ST markers or parsing control lines. This carrier is a Windows transport adaptation; it does not change the tmux protocol visible above the transport. + +### Consumed tmux records + +Windows Terminal currently acts on these control-mode records: + +| Record | Terminal behavior | +|--------|-------------------| +| `%begin`, `%end`, `%error` | Delimit and collect command replies. | +| `%output %pane ...` | Unescape tmux octal sequences and append output to the matching follower. | +| `%layout-change @window ...` | Reconcile live pane IDs and close stale follower UI. | +| `%window-pane-changed @window %pane` | Associate a pane with a tmux window and complete pending UI creation. | +| `%window-renamed @window name` | Update the corresponding Windows Terminal tab title. | +| `%session-window-changed ... @window` | Track the active tmux window for later UI actions. | +| `%exit` | Close follower UI and leave tmux mode cleanly. | + +Unknown notifications are ignored. Command reply text is retained only while a `%begin`/`%end` or `%error` block is active. + +### Commands emitted by Terminal + +Terminal translates native actions into standard tmux commands: + +| Windows Terminal action | tmux command | +|-------------------------|--------------| +| Type in a pane | `send-keys -H -t %pane 0x...` | +| Split a pane | `split-window -P -F '#{pane_id}' -t %pane -h/-v` | +| Open a tab or window | `new-window -P -F '#{pane_id}'` | +| Close a pane | `kill-pane -t %pane` | +| Resize a pane | `resize-pane -t %pane -x cols -y rows` | +| Resize the control client | `refresh-client -C colsxrows` | +| Detach | `detach-client` | + +Keyboard input is converted to UTF-8 and sent with hexadecimal `send-keys -H` arguments so spaces, control characters, and Unicode are not reinterpreted by the command parser. Windows win32-input-mode records are decoded, and UTF-16 surrogate pairs are preserved across input callbacks. + +Resize events are deduplicated and delayed by 75 ms. This avoids flooding the server with transient geometry while XAML animates a layout and prevents excessive blank lines from ConPTY resizes. + +Writes to the leader are serialized and occur off the UI/output-callback path. This prevents commands from being interleaved and avoids reentrant ConPTY writes that can deadlock the window. + +### Native UI mapping and affinities + +The mapping is: + +- tmux pane (`%N`) -> `TmuxFollowerConnection` and `TermControl` +- tmux window (`@N`) -> a Windows Terminal tab +- affinity group of tmux windows -> a Windows Terminal OS window + +The first native tmux tab creates a host OS window. A Windows Terminal **New Tab** action creates a tmux window in the source window's affinity group. **New Window** creates a tmux window in a new native host. Splits stay within the source tab and target pane. + +The grouping is stored in the tmux session option `@affinities`, using the iTerm2-compatible form: + +```text +0,1,3 2,4 +``` + +Each comma-separated group belongs in one native OS window. Because this is tmux session state, it survives a client detach and allows a replacement Windows Terminal client to reconstruct the same grouping. + +On attach, Terminal first runs `show-options -qv @affinities`, then requests layout replay with `refresh-client -C 80x24`. This ordering is intentional: initial layout notifications contain pane/window relationships but not native-host ownership. Terminal also tolerates an empty command reply already in flight ahead of the affinity query. These rules prevent reattach races from overwriting persisted multi-window groupings. + +### Gateway controls and teardown + +While control mode is active, the gateway accepts the iTerm2-style command menu keys: + +| Key | Action | +|-----|--------| +| `Esc` | Send `detach-client`, close all follower UI, and leave the tmux server running. | +| `X` | Force-close the control client and follower UI. | +| `L` | Toggle display of non-output protocol records in the gateway. | +| `C` | Prompt for and send an arbitrary tmux command. | + +Receiving ST or `%exit`, closing the leader, or detaching closes and unregisters all followers. Followers are silenced before their `TermControl`s are closed so late `%output` records and resize callbacks cannot write through a torn-down session. Closing an individual native pane sends `kill-pane`; server-driven layout changes close the corresponding native UI without echoing another close command. + +## Implementation types + +The integration is implemented under `src/cascadia/TerminalApp/`: + +| Type | Role | +|------|------| +| `TmuxProtocol` | Control markers, ConPTY carrier decoding, control-output unescaping, input decoding, and tmux layout parsing. | +| `TmuxLeaderConnection` | Wraps the gateway ConPTY, detects control mode, parses records, serializes commands, and owns client-size updates. | +| `TmuxFollowerConnection` | Virtual pane connection. Routes input and resize operations to tmux and receives `%output`. | +| `TmuxSession` | Maintains pane/window maps, affinity groups, pending command replies, UI creation, and teardown. | + +TerminalPage action handlers recognize these connections and intercept new-tab, new-window, split, duplicate, resize, and close operations while the session is active. + +## Configuration + +Put EternalTerminal's `htm.exe` and `htmd.exe` on `PATH`, or set `HTM_BIN_DIR` in the profile environment. Terminal prepends that directory to `PATH` for the profile's ConPTY: + +```json +{ + "profiles": { + "list": [ + { + "name": "tmux control mode", + "commandline": "htm.exe", + "environment": { + "HTM_BIN_DIR": "C:\\path\\to\\EternalTerminal\\build\\Release" + } + } + ] + } +} +``` + +The `commandline` must identify `htm` so Terminal installs the leader wrapper. `HTM_BIN_DIR` retains its name because it is part of EternalTerminal's executable discovery contract. + +## UI/UX + +1. Open a profile whose command line is `htm.exe`. +2. The gateway enters tmux control mode and displays its command menu. +3. Live tmux panes appear as native Windows Terminal windows, tabs, and panes. +4. Typing, splitting, opening tabs/windows, resizing, renaming, and closing are reflected in the tmux session. +5. Press `Esc` in the gateway to detach without killing the tmux session; reconnecting restores its panes and native-window affinity groups. + +## Capabilities + +### Accessibility + +Follower panes are normal `TermControl` instances. Screen readers and other accessibility features observe the same text buffer and UI Automation surface as ordinary terminal panes. No new pane chrome is introduced. + +### Security + +`htm` and `htmd` run as the current user. EternalTerminal's AF_UNIX endpoint is per-user under `%TEMP%`. Windows Terminal interprets control records only on a connection explicitly launched as the tmux gateway. `HTM_BIN_DIR` is an explicit profile environment setting. + +### Reliability + +The implementation accounts for fragmented carriers and records, concurrent input/resize/action writes, command-reply versus notification ordering, pane-creation races, late output during teardown, and persisted-affinity replay. Malformed or incomplete input remains buffered only where it can form a valid carrier, marker, or newline-delimited record. + +Unit tests cover transport decoding, control parsing, input conversion, and layouts. End-to-end tests in the EternalTerminal repository exercise Windows Terminal with `htm`/`htmd`, including layouts, stress, corner cases, affinity persistence, the control plane, races, and clean exit. + +### Compatibility + +The Terminal-side interface is tmux control mode and follows the `tmux -CC` model. The currently validated Windows provider is EternalTerminal's `htm`/`htmd`. Provider-specific names remain only where Terminal must launch or locate those binaries, or describe the ConPTY carrier used by that implementation. + +### Performance, power, and efficiency + +The gateway uses one wrapped ConPTY connection. Followers do not spawn local shell processes in Windows Terminal; `htmd` owns the real pane processes. Output is routed directly from `%output` records, and input, client writes, and resize updates are batched or serialized where necessary. + +## Known limitations and future work + +- Activation currently recognizes the EternalTerminal `htm` command line; it is not general discovery for every possible `tmux -CC` executable. +- The CSI `?777` carrier is specific to transporting control bytes through Windows ConPTY. +- Restoring a recently closed tmux pane through Windows Terminal's generic undo-close path is not supported as a tmux operation. +- A future connection-wrapper extension point could move provider-specific activation and transport adaptation out of TerminalApp. + +## Resources + +- tmux control mode (`tmux -CC`) and the tmux `CONTROL MODE` manual section +- EternalTerminal `src/htm/` and its `htm`/`htmd` executables +- [hyper-htm](https://github.com/MisterTea/hyper-htm) +- iTerm2 tmux integration and `@affinities` convention +- `DebugTapConnection` in TerminalApp +- Windows Terminal GH#4000 (extensibility) diff --git a/src/features.xml b/src/features.xml index 153102179ee..dd18ae65c32 100644 --- a/src/features.xml +++ b/src/features.xml @@ -189,7 +189,7 @@ Feature_TmuxIntegration - Detect TMUX init sequences and map Windows Terminal tabs/panes onto htmd + Detect tmux control mode and map Windows Terminal windows, tabs, and panes onto the tmux session AlwaysEnabled From 0717cb368808c6c55dd1e3c071ebb89bda763430 Mon Sep 17 00:00:00 2001 From: Jason Gauci Date: Wed, 16 Sep 2026 20:59:35 -0500 Subject: [PATCH 9/9] Resolve tmux integration spelling annotations Address every token reported by the GitHub Advanced Security spelling review. Reword documentation and comments to avoid unnecessary implementation jargon, clarify the refresh-client geometry placeholder, and describe follower cleanup without unrecognized wording. Add a narrow spelling pattern for tmux's literal %session-window-changed notification so the generic printf pattern does not reduce it to 'ession'. Remove the obsolete ADDREF expectation as requested by the bot. --- .github/actions/spelling/expect/expect.txt | 1 - .github/actions/spelling/patterns/patterns.txt | 3 +++ doc/specs/tmux-integration.md | 6 +++--- src/cascadia/TerminalApp/TmuxConnections.cpp | 8 ++++---- src/cascadia/TerminalApp/TmuxConnections.h | 4 ++-- src/cascadia/TerminalApp/TmuxProtocol.h | 4 ++-- src/cascadia/ut_app/TmuxProtocolTests.cpp | 2 +- 7 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.github/actions/spelling/expect/expect.txt b/.github/actions/spelling/expect/expect.txt index 11546f6cad1..3e5745e3b5d 100644 --- a/.github/actions/spelling/expect/expect.txt +++ b/.github/actions/spelling/expect/expect.txt @@ -12,7 +12,6 @@ acp actctx ACTCTXW ADDALIAS -ADDREF ADDSTRING ADDTOOL adml diff --git a/.github/actions/spelling/patterns/patterns.txt b/.github/actions/spelling/patterns/patterns.txt index 09510a0dfca..aa27301eaa2 100644 --- a/.github/actions/spelling/patterns/patterns.txt +++ b/.github/actions/spelling/patterns/patterns.txt @@ -163,6 +163,9 @@ mailto:[-a-zA-Z=;:/?%&0-9+@._]{3,} # hit-count: 2 file-count: 2 # Alternative printf +# tmux control-mode notification (must precede the generic %s pattern) +%session-window-changed + # %s %(?:s(?=[a-z]{2,}))(?!%)(?=[_a-zA-Z]+(?!%[^s])\b)(?=.*?['"]) diff --git a/doc/specs/tmux-integration.md b/doc/specs/tmux-integration.md index b9ed1ebcc5c..be4b553b06a 100644 --- a/doc/specs/tmux-integration.md +++ b/doc/specs/tmux-integration.md @@ -17,7 +17,7 @@ The work is gated by `Feature_TmuxIntegration`. It is enabled in non-Inbox build ## Background -iTerm2 introduced a native tmux integration based on `tmux -CC`. In control mode, the terminal is a tmux client: it receives structured notifications about output, layouts, and lifecycle events, and it sends tmux commands for input and UI operations. WezTerm implements the same model. +iTerm2 introduced a native tmux integration based on `tmux -CC`. In control mode, the terminal is a tmux client: it receives structured notifications about output, layouts, and lifecycle events, and it sends tmux commands for input and UI operations. Other terminal emulators implement the same model. EternalTerminal supplies a Windows tmux-compatible client and daemon through `htm.exe` and `htmd.exe`. On Windows, `htmd` owns the pane ConPTY processes and exposes tmux control mode to `htm`. Windows Terminal provides the native renderer and maps its UI actions back to tmux commands. @@ -97,7 +97,7 @@ Terminal translates native actions into standard tmux commands: | Open a tab or window | `new-window -P -F '#{pane_id}'` | | Close a pane | `kill-pane -t %pane` | | Resize a pane | `resize-pane -t %pane -x cols -y rows` | -| Resize the control client | `refresh-client -C colsxrows` | +| Resize the control client | `refresh-client -C x` | | Detach | `detach-client` | Keyboard input is converted to UTF-8 and sent with hexadecimal `send-keys -H` arguments so spaces, control characters, and Unicode are not reinterpreted by the command parser. Windows win32-input-mode records are decoded, and UTF-16 surrogate pairs are preserved across input callbacks. @@ -137,7 +137,7 @@ While control mode is active, the gateway accepts the iTerm2-style command menu | `L` | Toggle display of non-output protocol records in the gateway. | | `C` | Prompt for and send an arbitrary tmux command. | -Receiving ST or `%exit`, closing the leader, or detaching closes and unregisters all followers. Followers are silenced before their `TermControl`s are closed so late `%output` records and resize callbacks cannot write through a torn-down session. Closing an individual native pane sends `kill-pane`; server-driven layout changes close the corresponding native UI without echoing another close command. +Receiving ST or `%exit`, closing the leader, or detaching closes all followers and removes their registrations. Followers are silenced before their `TermControl`s are closed so late `%output` records and resize callbacks cannot write through a torn-down session. Closing an individual native pane sends `kill-pane`; server-driven layout changes close the corresponding native UI without echoing another close command. ## Implementation types diff --git a/src/cascadia/TerminalApp/TmuxConnections.cpp b/src/cascadia/TerminalApp/TmuxConnections.cpp index 77d860a186d..9eaf39a6140 100644 --- a/src/cascadia/TerminalApp/TmuxConnections.cpp +++ b/src/cascadia/TerminalApp/TmuxConnections.cpp @@ -40,8 +40,8 @@ namespace winrt::TerminalApp::implementation { if (_tmuxMode) { - // Stateful conversion: KEYEVENTF_UNICODE may deliver one surrogate - // per WriteInput; til::u16u8 without state would emit CESU-8. + // Stateful conversion: Unicode SendInput may deliver one surrogate + // per WriteInput; conversion without state would emit invalid UTF-8. const auto utf8 = til::u16u8(winrt_array_to_wstring_view(data), _u16ToUtf8); if (utf8.empty() || utf8 == "\x1b[I" || utf8 == "\x1b[O") { @@ -322,8 +322,8 @@ namespace winrt::TerminalApp::implementation { return; } - // Stateful conversion: KEYEVENTF_UNICODE may deliver one surrogate - // per WriteInput; til::u16u8 without state would emit CESU-8. + // Stateful conversion: Unicode SendInput may deliver one surrogate + // per WriteInput; conversion without state would emit invalid UTF-8. const auto utf8 = til::u16u8(winrt_array_to_wstring_view(data), _u16ToUtf8); if (utf8.empty() || utf8 == "\x1b[I" || utf8 == "\x1b[O") { diff --git a/src/cascadia/TerminalApp/TmuxConnections.h b/src/cascadia/TerminalApp/TmuxConnections.h index 1035fa06c50..1fe2b102aae 100644 --- a/src/cascadia/TerminalApp/TmuxConnections.h +++ b/src/cascadia/TerminalApp/TmuxConnections.h @@ -76,7 +76,7 @@ namespace winrt::TerminalApp::implementation std::string _paneId; std::mutex _writeMutex; bool _closed{ false }; - // SendInput KEYEVENTF_UNICODE delivers one UTF-16 code unit per call; + // Unicode SendInput events deliver one UTF-16 code unit per call; // hold high surrogates across WriteInput so emoji becomes real UTF-8. til::u16state _u16ToUtf8; ::Microsoft::Terminal::Tmux::Win32InputDecodeState _win32Decode; @@ -148,7 +148,7 @@ namespace winrt::TerminalApp::implementation bool _suppressClosePacket{ false }; bool _closed{ false }; std::string _pendingOutput; - // SendInput KEYEVENTF_UNICODE delivers one UTF-16 code unit per call; + // Unicode SendInput events deliver one UTF-16 code unit per call; // hold high surrogates across WriteInput so emoji becomes real UTF-8. til::u16state _u16ToUtf8; ::Microsoft::Terminal::Tmux::Win32InputDecodeState _win32Decode; diff --git a/src/cascadia/TerminalApp/TmuxProtocol.h b/src/cascadia/TerminalApp/TmuxProtocol.h index 47f8fb498bd..fd131738baa 100644 --- a/src/cascadia/TerminalApp/TmuxProtocol.h +++ b/src/cascadia/TerminalApp/TmuxProtocol.h @@ -20,7 +20,7 @@ namespace Microsoft::Terminal::Tmux // bytes after DCS are ordinary newline-delimited tmux control records. inline constexpr std::string_view TmuxControlDcs{ "\x1bP1000p" }; inline constexpr std::string_view TmuxControlSt{ "\x1b\\" }; - // iTerm2's tmux -CC gateway banner. WezTerm prints the same text. + // iTerm2's tmux -CC gateway banner. Other tmux clients print the same text. inline constexpr std::string_view TmuxCommandMenu{ "\r\n** tmux mode started **\r\n\r\n" "Command Menu\r\n" @@ -181,7 +181,7 @@ namespace Microsoft::Terminal::Tmux } } - // KEYEVENTF_UNICODE may deliver one UTF-16 code unit per win32-input-mode + // Unicode SendInput events may deliver one UTF-16 code unit per win32-input-mode // record; hold an unpaired high surrogate across DecodeWin32InputMode calls. struct Win32InputDecodeState { diff --git a/src/cascadia/ut_app/TmuxProtocolTests.cpp b/src/cascadia/ut_app/TmuxProtocolTests.cpp index 85a5f84322e..f67cdc402c1 100644 --- a/src/cascadia/ut_app/TmuxProtocolTests.cpp +++ b/src/cascadia/ut_app/TmuxProtocolTests.cpp @@ -105,7 +105,7 @@ namespace TerminalAppUnitTests TEST_METHOD(Win32InputModeSurrogatePairEmoji) { - // U+1F600 😀 arrives as two KEYEVENTF_UNICODE units (D83D DE00). + // U+1F600 😀 arrives as two Unicode SendInput units (D83D DE00). Win32InputDecodeState state; VERIFY_ARE_EQUAL("", DecodeWin32InputMode("\x1b[0;0;55357;1;0;1_", state)); VERIFY_ARE_EQUAL("\xF0\x9F\x98\x80", DecodeWin32InputMode("\x1b[0;0;56832;1;0;1_", state));