Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/actions/spelling/expect/expect.txt
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ CYSIZEFRAME
CYSMICON
CYVIRTUALSCREEN
CYVSCROLL
daemonizes
dai
DATABLOCK
dbcs
Expand Down Expand Up @@ -750,7 +751,9 @@ HTCAPTION
HTCLIENT
HTLEFT
HTMAXBUTTON
htmd
HTMINBUTTON
htmtst
HTRIGHT
HTTOP
HTTOPLEFT
Expand Down Expand Up @@ -1313,6 +1316,7 @@ PREVIEWWINDOW
PREVLINE
prg
PRIs
PROCESSENTRY
processhost
PROCESSINFOCLASS
PRODEXT
Expand Down Expand Up @@ -1570,6 +1574,7 @@ SMARTQUOTE
SMTO
snapcx
snapcy
SNAPPROCESS
snk
SOLIDBOX
Solutiondir
Expand Down Expand Up @@ -1696,6 +1701,7 @@ tmultiple
tofrom
Tombstoning
toolbars
Toolhelp
TOOLINFO
TOOLWINDOW
TOPDOWNDIB
Expand Down Expand Up @@ -1851,13 +1857,15 @@ WCIA
WCIW
wcs
WCSHELPER
wcsicmp
wcsrev
wcswidth
WCT
wddm
wddmcon
WDDMCONSOLECONTEXT
wdm
wdupenv
wekyb
wex
wextest
Expand Down
139 changes: 139 additions & 0 deletions doc/specs/htm-integration.md
Original file line number Diff line number Diff line change
@@ -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.<user>.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)
114 changes: 108 additions & 6 deletions src/cascadia/TerminalApp/AppActionHandlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -274,16 +297,71 @@ namespace winrt::TerminalApp::implementation
}
else if (const auto& realArgs = args.ActionArgs().try_as<SplitPaneArgs>())
{
const auto& duplicateFromTab{ realArgs.SplitMode() == SplitType::Duplicate ? _GetFocusedTab() : nullptr };

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
// arrives before the TermControl is the XAML focus target.
if (Feature_HtmIntegration::IsEnabled())
{
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{ session->CreateFollowerForUserSplit(sourceId, vertical) })
{
// Prefer splitting the focused follower tab; otherwise
// locate the source pane across windows.
if (htmConn && htmConn.try_as<HtmFollowerConnection>() && 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;
}

const auto& duplicateFromTab{ realArgs.SplitMode() == SplitType::Duplicate ? _GetFocusedTab() : nullptr };

const auto& activeTab{ _senderOrFocusedTab(sender) };

_SplitPane(activeTab,
realArgs.SplitDirection(),
// This is safe, we're already filtering so the value is (0, 1)
Expand Down Expand Up @@ -459,12 +537,14 @@ namespace winrt::TerminalApp::implementation
void TerminalPage::_HandleNewTab(const IInspectable& /*sender*/,
const ActionEventArgs& args)
{
const auto realArgs = args ? args.ActionArgs().try_as<NewTabArgs>() : nullptr;

if (args == nullptr)
{
LOG_IF_FAILED(_OpenNewTab(nullptr));
args.Handled(true);
return;
}
else if (const auto& realArgs = args.ActionArgs().try_as<NewTabArgs>())
else if (realArgs)
{
if (_shouldBailForInvalidProfileIndex(_settings, realArgs.ContentArgs()))
{
Expand Down Expand Up @@ -908,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)
Expand Down
Loading