Windows Terminal 多窗口会话管理:windowingBehavior与--window参数背后的 Emperor/Vassal 进程架构
【免费下载链接】terminalThe new Windows Terminal and the original Windows console host, all in the same place!项目地址: https://gitcode.com/GitHub_Trending/term/terminal
本文基于 Windows Terminal 仓库的规格文档 Windows Terminal Session Management 展开,系统讲解“单实例模式(Single Instance Mode)”与“在已有窗口中执行wt命令”两大能力的设计与实现:包括windowingBehavior设置的各取值语义、--window,-w参数如何寻址窗口、当前工作目录(CWD)如何跨进程传递,以及最近使用窗口(MRU)栈的分布式跟踪方案。读完本文,你将理解该特性从规格到源码(WindowEmperor.cpp、GlobalAppSettings.idl)的完整落地链路,并能在实际使用中正确配置与组合这些参数。
背景:Process Model 2.0 与 Monarch/Peasant 架构
本特性是 Process Model 2.0 规格文档 的一个补充。Process Model 2.0 解决的是 Windows Terminal 整体进程架构的改造——把“窗口进程”与“内容进程”分离,并引入 Monarch(君主)/Peasant(臣民)进程模型。而本文档聚焦其中一子集(对应 issue #5000、#4472、#2227):如何管理多个 Windows Terminal 窗口,具体包括:
- 在当前窗口中运行
wt命令(issue #4472); - 单实例模式(Single Instance Mode,issue #2227)。
Monarch/Peasant 架构的核心约定如下:
- 每一个 Windows Terminal 窗口都是一个 “Peasant” 进程;
- 其中恰好有一个窗口进程同时是 “Monarch” 进程。Monarch 从现有窗口中随机挑选产生,且同一时刻有且只有一个 Monarch;
- Peasant 在特定状态变化时(例如窗口被激活)向 Monarch 通信,Monarch 可以向任意 Peasant 下发命令。
当前仓库中的对应实现:WindowEmperor与AppHost
在仓库的实际实现中,这一 Monarch/Peasant 模型落地为WindowEmperor(帝王)与AppHost(每个窗口的主机,即臣民)两个核心类型。WindowEmperor.h 的类注释明确描述了其职责:
WindowEmperor 是负责管理“拥有全部窗口的单一 Terminal 进程”的类。它负责处理命令行参数,并首先尝试寻找另一个可通信的 terminal 进程;若找到,则把命令行“交接”(hand off)给已有进程;若判定应当创建窗口,则新设窗口线程,并在主线程上维护消息循环以处理全局状态(如全局热键、托盘通知图标)。
启动入口在 main.cpp:每个wt.exe进程启动后都会先make_shared<::WindowEmperor>()并调用emperor->HandleCommandlineArgs(nCmdShow),由 Emperor 统一决定“我是否要把这条命令行转交给别的窗口进程”。窗口查找则通过GetWindowById/GetWindowByName完成(见 WindowEmperor.h#L44-L45),这与规格中“每个窗口进程拥有由 Monarch 分配的唯一正整数 ID,也可拥有用户指定的字符串名称”的设定一一对应。
场景一:新标签页打开到“最近使用”的窗口(Glomming)
浏览器有一个常见行为:在任意位置点击一个 URL,该页面会以新标签页形式打开在“最近使用的窗口”中。这种新标签“吸附”(glom)到既有窗口的能力,规格中将其引入 Terminal:
- 此前 Terminal 不支持该特性——每次执行
wt都会创建一个新窗口; - 在 Emperor/Vassal 架构下,每个窗口被激活时会调用 Emperor 的一个方法,报告“我是窗口 N,我刚被聚焦”;Emperor 据此维护一个最近使用窗口(MRU)的内部栈;
- 每当一个新的
wt.exe进程启动,它会首先询问 Emperor:这条命令行应当放到已有窗口中执行,还是自己开窗口。
若 glomming 启用,Emperor 会把命令行分派给目标窗口处理,用户观感上就像标签页“自然”地开在了最近使用的窗口里。
windowingBehavior设置项
规格提出用windowingBehavior属性控制 glomming 行为,初始设计中列出三个取值:
"useExisting":无论虚拟桌面,总是 glom 到最近使用的窗口;"useExistingOnSameDesktop":仅当当前虚拟桌面上已有窗口时才 glom,否则新建窗口(规格拟将其作为默认值);"useNew":永不 glom,总是新建窗口——这也是 Terminal 当时的实际行为。
规格还计划把默认行为从useNew改为useExistingOnSameDesktop,以与带标签页的其他应用保持一致(详见下文“兼容性”讨论)。
源码中的枚举取值
在当前仓库中,该设置由WindowingMode枚举建模,定义于 GlobalAppSettings.idl#L33-L38:
enum WindowingMode { UseNew, UseAnyExisting, UseExisting, }其默认值在 MTSMSettings.h 中声明:windowingBehavior缺省为Model::WindowingMode::UseNew,即“每次wt都开新窗口”——与规格中“技术上这是 Terminal 当前行为”的描述一致,也说明规格中计划切换默认值的动作在本仓库中尚未落地(仍可通过设置显式改为其他取值)。
Emperor 的分发逻辑
Emperor 收到命令行后的完整判定流程见 WindowEmperor.cpp#L805-L871。其逻辑可概括为:
const auto parsedTarget = args.TargetWindow(); WindowingMode windowingBehavior = WindowingMode::UseNew; uint64_t windowId = 0; winrt::hstring windowName; if (parsedTarget.empty()) { // 未指定目标:回落到全局 windowingBehavior 设置 windowingBehavior = _app.Logic().Settings().GlobalSettings().WindowingBehavior(); } else if (const auto opt = til::parse_signed<int64_t>(parsedTarget, 10)) { // 负数 ID 视为 UseNew;0 视为 UseExisting if (*opt > 0) { windowId = gsl::narrow_cast<uint64_t>(*opt); } else if (*opt == 0) { windowingBehavior = WindowingMode::UseExisting; } } else if (parsedTarget == L"last") { // 保留名 last:glom 到最近使用的窗口 windowingBehavior = WindowingMode::UseExisting; } else if (parsedTarget != L"new") // 保留名 new:总是新窗口 { windowName = parsedTarget; // 其余字符串一律当作窗口名 } // 之后:按 windowId / windowName 精确查找, // 否则按 UseAnyExisting 取 _mostRecentWindow(), // 按 UseExisting 走 _dispatchCommandlineCurrentDesktop()(当前虚拟桌面 MRU)。这段实现印证了规格的几条设计决策:
- 省略目标时遵循
windowingBehavior设置; -w 0被解释为UseExisting(在当前虚拟桌面的最近窗口执行),而非“最近窗口的别名”——这正对应规格中“wt -w 0在 WT 外部执行时,应当 glom 到最近的 WT 窗口”的结论(规格脚注 [2] 中提议的last/current特殊值,实现中落地为保留名last);- 负数或
new强制新建窗口,与windowingBehavior无关; last作为保留名与0同义,用于显式表达“最近使用的窗口”。
当前工作目录(CWD)的跨进程处理
规格给出了一个典型场景:用户在explorer.exe地址栏中执行wt -d .,Emperor 判定该标签页应当创建在既有窗口中。为叙述方便,把既有窗口记作 WT[1],新启动的wt.exe进程记作 WT[2]。
此时希望新标签页在WT[2] 的当前工作目录中启动,而不是 WT[1] 的目录。因此当 WT[1] 准备执行“原本传给 WT[2] 的命令”时,它需要:
- 先暂存(stash)自己当前的 CWD;
- 切换到 WT[2] 的 CWD;
- 执行 WT[2] 传来的命令;
- 恢复到自己原来的 CWD。
据此,规格要求在“Peasant 向 Monarch 传递启动命令行”的接口中,同时携带当前工作目录。当前实现中这一要求体现在命令行分发接口CommandlineArgs上:_dispatchCommandlineCommon(WindowEmperor.cpp#L874-L882)会设置c.CurrentDirectory(...)与c.CurrentEnvironment(...),随后统一经_dispatchCommandline交给目标窗口的DispatchCommandline,保证目标窗口拿到的是“发起方”的 CWD 与环境。
场景二:--window,-w参数与窗口寻址
另一个高频需求是:让wt.exe命令行在当前窗口执行,而不是永远新建窗口。规格将其推广为“在任意指定的 WT 窗口中执行wt命令行”。窗口身份由两部分构成:Monarch 分配的唯一正整数 ID(窗口总是有 ID),以及用户可指定的字符串名称(不一定有)。于是有如下用法:
wt.exe --window N new-tab ; split-pane或简写wt -w N new-tab ; split-pane。规格为该参数定义了如下语义(当前实现与之一致,见上文源码片段):
window-id为0:在当前窗口执行(在 WT 外部运行时,glom 到最近的 WT 窗口);window-id为负数或保留名new:在新Terminal 窗口执行;window-id为某个已有窗口的 ID 或名称:在该窗口执行;window-id不是任何已有窗口的 ID 或名称:创建一个新窗口,并把该 ID/名称赋给它,随后在新窗口中运行子命令;window-id省略:按windowingBehavior取值决定。
一个关键设计约束是:每次wt.exe启动,都必须首先把命令行交给 Emperor 进程处理——这是 glomming 场景成立的前提。Emperor 解析命令行、确定目标窗口,再调用该窗口(Peasant)的ExecuteCommandline(实现中即AppHost::DispatchCommandline)执行命令。
--window只作用于wt.exe本身,不作用于子命令
--window是wt.exe自身的参数,而非new-tab、split-pane等子命令的参数。因此一条wt命令行中的所有子命令都由同一个会话处理。例如,想“在窗口 2 打开新标签、同时在窗口 3 分割窗格”,不能这样写:
wt -w 2 new-tab ; -w 3 split-pane而必须拆成两条独立的wt.exe调用:
wt -w 2 new-tab & wt -w 3 split-pane规格对此给出的理由很具体:若--window属于每个子命令,则每个子命令的解析器都必须理解该参数,且命令行的任意一部分都可能触发跨进程调用。源进程需要某种机制来“等待目标进程回报子命令完成”后,才能继续分派命令行的下一段——这在进程间协调上得不偿失。因此采用“整批命令一起分派”这一更简单可靠的方案。
为什么wt -w 0不能简单依赖 MRU
规格特别辨析了一个看似简单、实则错误的“当前窗口”实现:如果 Peasant 总是在窗口聚焦时上报事件、Emperor 维护 MRU 顺序,那么wt -w 0似乎总能返回“用户正在输入的那个窗口”。但考虑:
sleep 10 ; wt -w 0 <commands>在sleep的 10 秒内,用户可能切换到另一个 WT 窗口,导致 MRU 窗口与正在执行该命令的窗口不一致。
为此,规格考虑过通过WT_SESSION环境变量定位会话(由 Emperor 反查该 GUID 对应的 Peasant),并评估了引入WT_WINDOW_ID环境变量的方案:它可以省去按会话 ID 反查的步骤,但存在两个顾虑——一是把窗口 ID 暴露成环境变量后,用户会倾向于绕过wt -w 0这类“替用户把活干完”的别名直接用它;二是当标签页被“撕出”(tear out)成新窗口时,子进程中的WT_WINDOW_ID不会更新。两个方案还共同面临“用户把变量改成乱值”的风险,而使用 GUID 形式的会话 ID 比 int 形式的窗口 ID 更不容易被猜中。
窗口命名(Naming Windows)
仅靠自动生成的不可见数字来识别窗口并不友好。为此规格允许用户为窗口指定字符串名称,名称与 ID 一样可用于寻址窗口:
- 名称可在原始命令行中直接给出,例如
wt -w foo nt会把新窗口命名为foo; - 也可通过新的动作
NameWindow设置(规格脚注 [3] 指出这与已有renameTab(name)/openTabRenamer()两个“重命名标签页”动作对偶,未来可能还需要nameWindow(name)/openWindowNamer())。作为子命令使用,例如wt -w 4 name-window bar会把 4 号窗口命名为bar; - 禁止整数名称(正负均可),防止把窗口改名为
2后wt -w 2产生歧义; - 名称必须唯一:尝试重命名为已存在的名称时,忽略该次改名,并可弹出一个低干扰的提示(toast),文案类似 “Unable to rename window. Another window with that name already exists”;
- 保留名:Terminal 保留名称
new,并保留所有以_开头的名称——这样未来可以添加新的关键词而不引入破坏性变更。
当前实现中,窗口名与 ID 均通过 Emperor 的GetWindowByName/GetWindowById精确查找,且WindowEmperor内部维护WindowListEntry{ uint64_t Id; std::wstring Name; }列表(WindowEmperor.h#L37-L41),供WM_GET_WINDOW_LIST消息同步返回——这正对应规格“未来考虑”一节中“必须提供一个列出窗口及其 ID、名称的命令行工具”的诉求。
UI/UX 设计:windowingBehavior各取值的完整行为
规格对windowingBehavior各取值的行为给出了明确矩阵:
| 取值 | 行为描述 |
|---|---|
"useExisting"、"useExistingOnSameDesktop" | 浏览器式 glomming:新实例默认打开在当前(最近)窗口;newWindow动作开新窗口;标签可撕出为新窗口;wt -w -1开新窗口 |
"useNew" | 不自动 glom——这是 Terminal 当前行为:新实例默认开新窗口;newWindow开新窗口;标签可撕出为新窗口;wt -w -1开新窗口 |
从源码看,UseAnyExisting(对应规格中的useExisting)在分发时调用_mostRecentWindow()直接取全局最近窗口;UseExisting(对应useExistingOnSameDesktop)则走_dispatchCommandlineCurrentDesktop()(WindowEmperor.cpp#L885-L907):先经VirtualDesktopUtils::GetCurrentVirtualDesktopId取得当前虚拟桌面 ID,再遍历所有窗口,比较每个窗口的虚拟桌面与GetLastActivatedTime(),选出当前桌面上最近激活的那个——“仅在当前桌面有窗口才 glom,否则新建”的语义由此实现。
关注点分析:安全、可靠性与兼容性
安全:COM 按提级级别隔离
在实例化 Emperor(规格中的 Monarch)对象时,COM 只会返回同一提级级别(elevation level)的服务进程对象。因此不必担心未提级的 Peasant 连到提级的 Emperor,或反之。这也简化了规格后续确认的“混合提级(mixed-elevation)”决策:自 2020 年 12 月起不再追求混合提级场景,提级与未提级的wt实例始终彼此独立,各自维护自己的窗口 ID 列表;若用户同时运行提级与未提级窗口,则存在两个 Emperor——一个提级、一个未提级。
可靠性:跨进程对象调用必须容错
与“由另一个进程托管的对象”打交道时必须格外小心:任何此类操作都必须包在 try/catch 中,因为对端进程随时可能被杀。Emperor 与 Peasant 两侧代码都必须对该场景冗余处理,在对端被杀时给出适当错误提示并优雅恢复或退出。同时,在这类情况下一切日志应尽量详尽,便于事后定位错误发生在哪个进程。
兼容性:默认行为变更的回滚路径
把默认行为改为“自动 glom 到同桌面最近窗口”是一项破坏性的 UX 变更,但可以通过"windowingBehavior": "useNew"设置回退。规格承认这是对 Terminal 默认体验的一次较大改动,计划通过 Preview 版分阶段投放、发布说明中特别提示、并收集用户反馈后再决定是否全面铺开;还可能选择在“标签撕出(tab tear out)”功能可用之后再切换默认值,以便对 glomming 不满的用户至少可以撕出标签自救。
潜在问题与边界情形
--window与其他命令行选项的组合
有些wt.exe命令行选项在“转交给另一个会话执行”时没有意义,包括但不限于:
--initialSize r,c--initialPosition x,y--fullscreen、--maximized等
当命令行被转交给已有实例处理时,这些参数会被忽略——它们只适用于窗口的初始创建:对一个已有大小的窗口说--initialSize 32, 120并不成立。另外,新建窗口启动时当前假设第一条命令总是new-tab;而在向已有窗口传递命令行时,无需再作此假设,因为那里已经存在既有标签页。
提级组合的命令行陷阱
一个需要特别处理的边界:用户从未提级的explorer.exe中执行
wt -w 0 new-tab -d . --elevated希望“在当前(提级)窗口里开新标签”。常规流程是先确定命令行目标窗口再分派。这里-w 0会把命令行交给当前的未提级窗口;随后该窗口尝试打开提级标签页、失败并创建新的wt.exe进程——而第二个wt.exe进程丢失了-w 0的上下文,不会告知提级 Emperor“这条命令行应在活动会话中执行”。因此创建提级实例时必须保留传入的--window参数。
Emperor 侧 MRU 栈的丢失问题
Emperor 负责跟踪 MRU 窗口栈,但当 Emperor 进程被关闭时,这份状态会丢失:新选出的 Emperor 无法向旧 Emperor 询问旧 MRU 顺序。规格为此列出三个层次的方案:
- 可接受的降级 UX:随机化顺序(新 Emperor 自己成为 MRU 窗口);若用户抱怨,可让每个 Peasant 在激活时不仅通知 Emperor、还通知所有其他 Peasant,使所有 Peasant 同步跟踪 MRU 栈,从而随时有资格成为 Emperor;
- 更简单的分布式方案:Emperor 根本不维护 MRU 栈,而是每个 Peasant 内部记录自己的
LastActivated时间戳;Emperor 需要时遍历所有 Peasant,找出时间戳最新者; - 进一步优化:Emperor 也缓存一份栈以便快速查询,
LastActivated时间戳仅用于新 Emperor 当选时重建栈。
从源码结构看,当前实现采用了方案 2:Emperor 不持有自己的 MRU 栈,而是在需要时遍历_windows并读取每个窗口的GetLastActivatedTime()(同桌面场景见 WindowEmperor.cpp#L890-L902),状态分布式保存在各 Peasant 中,Emperor 更换不会造成 MRU 状态丢失。
实施计划
规格最后给出了可执行任务清单(原文为待办列表):
- 支持
wt.exe进程充当 Monarch 与 Peasant,并在它们之间通信该状态(纯架构更新,不引入用户可见特性); - 以布尔形式支持
windowingBehavior设置,新 WT 窗口条件性地 glom 到已有窗口; - 通过引入
"useExisting"、"useExistingOnSameDesktop"、"useNew"枚举值,支持按虚拟桌面的windowingBehavior; - 通过
--window,-w window-id命令行参数,支持wt.exe把“意在另一窗口的命令行”经 Monarch 传给目标窗口; - 支持通过
-w参数按 ID 与名称寻址、命名窗口; - 增加
NameWindow动作/子命令,允许用户设置窗口名称; - 增加一个让所有窗口短暂显示“当前窗口 ID 与名称”覆盖层的动作,类似 Windows “显示”设置中的“标识”(identify)功能。
对照仓库现状,上述多数条目已有对应落地:Emperor/Vassal 通信(WindowEmperor 与 AppHost)、三值枚举的windowingBehavior(GlobalAppSettings.idl)、-w寻址与窗口命名(TargetWindow解析与GetWindowById/GetWindowByName),以及“标识”功能对应的WM_IDENTIFY_ALL_WINDOWS消息(WindowEmperor.h#L25-L32)。
未来考虑
规格还留了几个开放方向:
- 管道输入到既有窗口的窗格:
man ping > wt -w 0 split-pane cat这类用法需要 WT 把 stdin/out 句柄传给目标进程,规格认为其归属更靠近 issue #492(默认终端)或独立规格;更朴素的替代写法wt -w 0 split-pane -- man ping > cat无需额外工作即可实现同等效果; - 真正的单实例模式:只允许存在一个 WT 窗口。早期版本曾提议
glomToLastWindow的always取值(后更名为windowingBehavior),它会禁用标签撕出、禁用newWindow动作并阻止wt -w new开新窗口;但评审中结论是“该设置改变撕出行为”并不自洽,真正的单实例模式被留给 Quake Mode(issue #653,见 Quake Mode 规格)处理; - 自动生成窗口名:参考 gfycat.com/what3words.com 的随机词组或 docker 的“形容词+名字”风格自动生成名称——因本地化代价大而搁置;
- 窗口列表工具:需要一个列出窗口 ID、名称、PID 与标题的命令行工具。规格指出
wt.exe是 GUI 应用、无法向控制台打印,因此需要额外发布一个命令行辅助 exe,其设计留待未来。
参考资源
- 本文主体文档:Windows Terminal Session Management(作者 Mike Griese,关联 issue #4472,是 Process Model 2.0 Spec 的补充);
- 核心实现:WindowEmperor.h、WindowEmperor.cpp、main.cpp、AppHost.h;
- 设置模型:GlobalAppSettings.idl、MTSMSettings.h(
windowingBehavior的默认值与序列化键); - 相关规格:Tab tearoff(#1256)、Quake Mode(#653)、Process Model 2.0(#5000)。
【免费下载链接】terminalThe new Windows Terminal and the original Windows console host, all in the same place!项目地址: https://gitcode.com/GitHub_Trending/term/terminal
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考