Windows Terminal 新标签菜单定制(newTabMenu):从设计规格到源码实现
本文以官方规格文档 #1571 - New Tab Menu Customization 为主体,完整讲解 Windows Terminal 中 "newTabMenu" 设置项的设计动机、六种菜单条目类型(profile / separator / folder / action / remainingProfiles / matchProfile)的配置语法与行为规则,并结合仓库中 TerminalSettingsModel 与 TerminalApp 的实际源码,说明这套菜单结构是如何被反序列化、过滤、展开并渲染为 XAML 下拉菜单的。读完后你可以直接写出可落地的 settings.json 定制方案,并能理解 openNewTabDropdown 等关键绑定背后的实现逻辑。
一、背景:为什么需要定制新标签下拉菜单
Windows Terminal 允许用户在配置中定义大量 profiles,动态 profile 生成器(如 WSL、Visual Studio、PowerShell Core 等)还会在运行时继续追加 profile。当 profile 数量变多、且其中不少并不常用时,"新标签"下拉菜单会变得非常杂乱。用户普遍希望有一种方式对这个下拉列表进行重排和重新组织。
规格文档 doc/specs/#1571 - New Tab Menu Customization/#1571 - New Tab Menu Customization.md(作者 Mike Griese,创建于 2020-5-13,最后更新于 2022-11-18)给出的方案是:新增一个名为 "newTabMenu" 的全局设置。
- 未设置时(默认):新标签菜单按用户在配置文件中出现的顺序,填入全部 profile;
- 已设置时:由用户完全控制下拉菜单中呈现的内容与结构。
二、核心配置语法:newTabMenu 的完整示例
规格给出的示例配置如下(原文完整保留):
{
"profiles":{ ... },
"newTabMenu": [
{ "type":"profile", "profile": "cmd" },
{ "type":"profile", "profile": "Windows PowerShell" },
{ "type":"separator" },
{
"type":"folder",
"name": "ssh",
"icon": "C:\\path\\to\\icon.png",
"entries":[
{ "type":"profile", "profile": "Host 1" },
{ "type":"profile", "profile": "8.8.8.8" },
{ "type":"profile", "profile": "Host 2" }
]
},
{ "type":"separator" },
{ "type":"profile", "profile": "Ubuntu-18.04" },
{ "type":"profile", "profile": "Fedora" }
]
}
使用该配置后,新标签下拉菜单呈现为:cmd、Windows PowerShell 两个顶层条目,一条分隔线,一个名为 "ssh" 的嵌套文件夹子菜单(含 3 个远程主机 profile),再一条分隔线,最后是 Ubuntu-18.04 与 Fedora。
六种条目类型逐一详解
newTabMenu 数组中的对象共有六种 type:
1. "type":"profile" — 单个 profile 条目
点击该条目会用指定 profile 打开新标签。profile 通过 "profile" 参数标识,接受 profile 的 name 或 GUID。条目的图标取 profile 自身的 icon,显示文本取 profile 的名称。
2. "type":"separator" — 分隔线
对应 XAML 的 MenuFlyoutSeparator,用于在视觉上分隔条目组。
3. "type":"folder" — 嵌套子菜单
"name":显示在组上的文本;"icon":图标的图片路径,可选;"entries":嵌套在该条目下的子条目列表,子条目里可以再次包含"type":"folder",即支持任意深度嵌套;"inline":取值两种auto:当文件夹内只有一个条目时,不再生成嵌套层,直接把该唯一条目放在文件夹本应占据的位置(对只有一个条目的动态 profile 来源很有用);never(默认):即使只有一个子项也始终创建嵌套条目。
"allowEmpty":强制该条目即使在没有任何 profile 时也显示在菜单中,默认false(空文件夹在生成菜单时直接忽略)。为true且文件夹为空时,应添加一个占位<empty>条目提示该文件夹中没有 profile。规格明确了四种组合的行为:
| allowEmpty | inline | 行为 |
|---|---|---|
true |
auto |
完全忽略该条目,不向父级添加占位 |
true |
never |
添加嵌套条目,内含 <empty> 占位 |
false |
auto |
完全忽略该条目 |
false |
never |
完全忽略该条目 |
4. "type":"action" — 执行快捷键动作
表示一个执行特定 ShortcutAction 的菜单条目。
"id"属性指定全局动作 ID(即全局动作列表,见 issue #6899 / #7175),用于标识用户选中该条目时执行的动作;ID 无效的动作会被忽略并省略;- 条目文本取动作的 label(全局动作列表中显式提供的
"name",未提供则为生成的名称); - 图标同样复用动作的
icon。
5. "type":"remainingProfiles" — 兜底条目
一个特殊条目,会展开为所有尚未被列入菜单的 profile 对应的 "type":"profile" 条目,让用户只需一条配置就能补上"我没有手动加进菜单的全部 profile"。
- 该类型只能出现一次:重复添加会发出警告,且除第一个外全部被忽略;
- 也可以放在
folder内,实现"顶层只突出几个 profile,其余全部仍可通过某个子文件夹访问"; - 展开出的条目名称即 profile 名称,图标即 profile 图标;
- 不会包含通过
matchProfile条目已经引入的 profile。
6. "type":"matchProfile" — 按条件批量匹配
展开为所有匹配给定字符串的 profile,让用户无需手工逐个添加即可把一整批 profile 收进某个文件夹。
"name"、"commandline"或"source"三个属性用于按 profile 自身对应属性过滤,取值是与 profile 对应属性做完整字符串比较的字符串(规格明确:不是正则、不是部分匹配)。
规格还指出,若选择本路线,也应支持命令面板(Command Palette)所使用的 { "key": "SomeResourceString"} 资源字符串语法来指定可本地化的名称。
三、"默认菜单"的等价表达与默认值建议
规格文档认为,"默认"新标签菜单可以想象成如下 JSON:
{
"newTabMenu": [
{ "type":"remainingProfiles" }
]
}
也即:默认行为 ≈ 菜单里放一个 remainingProfiles 条目。
文档进一步给出了一个更结构化的候选默认(userDefaults.json 是用户新建 settings 文件时所用模板,见 userDefaults.json):
{
"newTabMenu": [
{ "type":"profile", "profile": "cmd" },
{ "type":"profile", "profile": "Windows PowerShell" },
{ "type": "matchProfile", "source": "Microsoft.Terminal.PowerShellCore" }
{
"type": "folder",
"name": "WSL",
"entries": [ { "type": "matchProfile", "source": "Microsoft.Terminal.Wsl" } ]
},
{
"type": "folder",
"name": "Visual Studio",
"entries": [ { "type": "matchProfile", "source": "Microsoft.Terminal.VisualStudio" } ]
},
// ... 其他 profile 生成器同理
{ "type": "remainingProfiles" }
]
}
该方案会把 cmd、PowerShell 及所有 PowerShell Core 放在根部顶端,其后是各动态 profile 生成器的嵌套条目。规格作者建议仅把这种结构写入 userDefaults.json,避免"动了用户的奶酪"——如果用户已经习惯当前列表(例如默认 profile 是某个 WSL 发行版,改造后从一次点击变成两次点击),就不应被强行改变。
四、源码实现剖析:从 JSON 到 XAML 菜单
结合仓库源码可以看到,该规格已经落地为 TerminalSettingsModel 项目中的一组 WinRT 类型,并遵循"基类 + 类型分派"的结构。
4.1 条目类型与 JSON 反序列化分派
NewTabMenuEntry.cpp 中定义了反序列化入口 FromJson:读取 "type" 键,然后按 NewTabMenuEntryType 枚举分派到六种具体实现:
Separator→SeparatorEntryFolder→FolderEntryProfile→ProfileEntryRemainingProfiles→RemainingProfilesEntryMatchProfiles→MatchProfilesEntryAction→ActionEntry
无法识别的 type 返回 nullptr(对应"无效条目被忽略"的规格约定)。基类 NewTabMenuEntry.h 的构造函数是受保护的,消费方不能直接实例化基类,只能经过 FromJson 或某个子类构造,这与 JSON 中 type 分派的唯一入口保持一致。
newTabMenu 作为可继承的全局设置在 GlobalAppSettings.idl 中声明为:
INHERITABLE_SETTING(IVector<NewTabMenuEntry>, NewTabMenu);
即它是继承型设置(可被 profiles 之外的顶层设置覆盖),值为 NewTabMenuEntry 的向量。
4.2 folder 的空条目过滤逻辑
FolderEntry.cpp 精确实现了规格中 inline/allowEmpty 的组合行为。其 Entries() 方法只对真正要渲染到 WinRT 侧的条目做过滤,把折叠/展开逻辑集中在一处。关键规则(源码中的注释与逻辑):
Profile条目:若指向的 profile 解析失败(Profile()为空)则被过滤;RemainingProfiles/MatchProfiles集合:若展开后Profiles().Size() == 0则被过滤;Folder条目:若其有效条目数为 0,且(不允许为空 或 inline 为Auto)则被过滤——即只有allowEmpty:true且inline:never的空文件夹会保留(渲染时显示<empty>占位,见下文TerminalPage的NewTabMenuFolderEmpty字符串);- 图标路径会经
ResolveMediaResourcesWithBasePath解析,条目内实现IPathlessMediaResourceContainer的子条目被递归解析。
4.3 matchProfile 的匹配实现
规格最初明确采用完整字符串匹配,把正则留作未来扩展(见"Future considerations")。但当前仓库的实现已经前进了一步:MatchProfilesEntry.cpp 在 FromJson 中读取 "name"、"commandline"、"source" 三个键后,会调用 _validateAndPopulateNameRegex 等函数,通过 til::ICU::CreateRegex 把取值编译为 ICU 正则表达式;MatchesProfile 则用 uregex_matches 对 profile 的 Name()、Source()、Commandline() 依次做正则匹配,任一命中即算匹配。从源码结构看,当前实现实际上是"ICU 正则匹配"(完全匹配的字符串自然是合法正则的特例),这与规格"未来考虑正则"的演进方向一致。若正则在解析期无效,ValidateRegexes 返回 false,对应条目不会被使用。
4.4 菜单渲染:TerminalPage 构建 MenuFlyout 条目
在 UI 层,TerminalPage.cpp 负责把设置模型转换为实际的下拉菜单:
_CreateNewTabFlyoutItems(IVector<NewTabMenuEntry> entries)(约 L1155 起)对条目做 switch 分派:Separator生成MenuFlyoutSeparator;Folder生成含子菜单的条目(空文件夹时插入NewTabMenuFolderEmpty占位文本);RemainingProfiles/MatchProfiles按展开出的 profile 列表逐一生成条目;Profile生成单个 profile 条目;Action生成动作条目。规格中"profile 图标 + 快捷键 accelerator 文本"的要求也在此处落实——与现在 UI 一致,若有键绑定打开该 profile 的新标签,则作为 accelerator 文本显示在MenuFlyoutItem上;- 文件末尾固定追加 "Settings"、"Feedback"、"About" 三个条目并以
MenuFlyoutSeparator分隔,与规格"UI/UX Design"一节"这三项始终存在、不可被该功能移除"的约定一致; - 约 L1439 处的处理函数对应
openNewTabDropdown键绑定(见下节)。
单元测试位于 NewTabMenuTests.cpp,序列化层面的覆盖见 SerializationTests.cpp;设置编辑器(Settings UI)侧的菜单编辑页在 NewTabMenu.cpp。
五、对键绑定的影响:index 语义与 openNewTabDropdown
这是规格中"Potential Issues"一节,也是落地时最重要的兼容性考量。
5.1 openTab / splitPane 的 index 参数语义变化
此前 openTab 与 splitPane 的 index 参数有两种含义:
- "用第 N 个 profile 创建新标签/窗格";
- "用新标签下拉菜单中第 N 项的 profile 创建新标签/窗格"。
两者过去是同义的,因为"第 N 个 profile"总是下拉菜单中的第 N 项。引入 newTabMenu 后二者不再等价,规格明确:index 参数的含义显式改为第一种——"用第 N 个 profile 打开标签/窗格"(profile 列表的顺序,而非菜单顺序)。
5.2 openNewTabDropdown 的 index 行为
为覆盖"按下按键直达菜单中某一项"的场景,考虑给 openNewTabDropdown 动作增加 index 参数。由于菜单中第 N 项未必是 profile(可能是文件夹或动作),规格定义了如下行为("type":"separator" 在计数时忽略):
- 第 N 个顶层条目是
"type":"profile":执行针对该 profile 的newTab或splitPane; - 是
"type":"folder":聚焦其子菜单的第一个元素,用户可继续用键盘导航; - 是
"type":"action":直接执行该动作。
规格给出的示例菜单:
New Tab Button ▽
├─ Folder 1
│ └─ Profile A
│ └─ Action B
├─ Separator
├─ Folder 2
│ └─ Profile C
│ └─ Profile D
├─ Action E
└─ Profile F
配合键绑定:
{
"bindings":
[
{ "command": { "action": "openNewTabDropdown", "index": 0 }, "keys": "ctrl+shift+1" },
{ "command": { "action": "openNewTabDropdown", "index": 1 }, "keys": "ctrl+shift+2" },
{ "command": { "action": "openNewTabDropdown", "index": 2 }, "keys": "ctrl+shift+3" },
{ "command": { "action": "openNewTabDropdown", "index": 3 }, "keys": "ctrl+shift+4" }
]
}
行为分别是:
- Ctrl+Shift+1:聚焦 "Profile A",需再按 Enter/Space 才创建新标签/窗格;
- Ctrl+Shift+2:聚焦 "Profile C",需再按 Enter/Space;
- Ctrl+Shift+3:直接执行 Action E;
- Ctrl+Shift+4:直接用 Profile F 创建新标签/窗格。
(注意:"type":"folder"、"type":"separator" 不计入顶层索引,所以 index 0/1 分别指向 Folder 1、Folder 2。)
六、关键设计决策:为什么不复用 profiles 列表结构
规格"Other considerations"一节记录了被否决的替代方案:直接给 profiles 列表本身加入结构,例如:
"profiles": {
"defaults": {},
"list":
[
{ "name": "cmd" },
{ "name": "powershell" },
{ "type": "separator" },
{
"type": "folder" ,
"profiles": [
{ "name": "ubuntu" }
]
}
]
}
否决理由:
- 这会给 profile 列表对象的内容带来不必要的复杂性;设计上希望
profiles列表只包含Profile对象,让 JSON 的其他部分去引用这些 profile; - 会产生意外的耦合行为:如果有人想让某个动作"打开新标签用第 4 个 profile",而他又把该动作设成了 profile 列表的第 4 项——列表的"位置"含义就混乱了;
- 无法表达更多场景:比如一个条目想开一个含两个不同 profile 窗格的标签,或同一 profile 在菜单中出现两次;
- 重载
profiles结构会迫使所有其他 profile 列表消费方都关心列表元素的结构,而它们本应只关心"profile 列表"本身;同时把动作混进 profile 列表也会让 profile 列表本身更复杂。
规格最终选择的方案更干净地分离了"profile 列表"与"新标签菜单内容"两个职责:两者各自独立定义、互不依赖。
matchProfile 的展开顺序
为实现 matchProfile,构建菜单时按如下顺序求值:
- 所有显式
profile条目; - 所有
matchProfile条目(使用尚未被指定过的 profile); - 最后用
remainingProfiles展开上述未覆盖到的剩余 profile。
规格示例:
{
"newTabMenu": [
{ "type": "matchProfile", "source": "Microsoft.Terminal.Wsl" }
{
"type": "folder",
"name": "WSLs",
"entries": [ { "type": "matchProfile", "source": "Microsoft.Terminal.Wsl" } ]
},
{ "type": "remainingProfiles" }
]
}
对 profile 集合 { "Profile A", "Profile B (WSL)", "Profile C (WSL)" },展开结果为:
New Tab Button ▽
├─ Profile A
├─ Profile B (WSL)
├─ Profile C (WSL)
└─ WSLs
└─ Profile B (WSL)
└─ Profile C (WSL)
即同一 profile 可以出现在多处:顶层的 matchProfile 和文件夹内的 matchProfile 都命中 WSL 系列,而 remainingProfiles 不再重复包含它们("不会被 matchProfile 包含的 profile 之外再有")。
七、能力面:可访问性、安全与兼容性
- 可访问性:菜单以与现行新标签 flyout 完全相同的方式加入 XAML 树,预期无显著变化;
- 安全 / 可靠性 / 兼容性 / 性能功耗:规格均标注"预期无变化"。
八、未来方向(Future considerations)
规格列举了以下尚待考虑的扩展:
-
允许用户对菜单条目手工设置
"name"/"text"或"icon",覆盖来自 profile 或动作的默认值(完全可选); -
为所有 folder / action 提供默认图标(如文件夹用 📁、动作用 ⚡),当前默认不设置,留待未来评估;
-
"选中某生成器的全部 profile"这类更复杂的过滤语法(例如把所有
SSH*profile 收进 "SSH" 文件夹),因需要更复杂的属性筛选,被判定超出本规格范围; -
类似结构可复用于定制 control 上下文菜单或标签页上下文菜单(issue #3337);那时可能需要一种方式在 JSON 中引用当前标签/控件的上下文,例如把"关闭标签"实现为
{ "action": "closeTab", "index": "${selectedTab.index}" }这类占位符语法; -
为
matchProfile引入正则、标签等匹配方式(规格原本考虑默认用正则,但为给正则留余地而先采用完整字符串匹配;若走向正则,建议使用如下结构化写法):"type": "profileMatch", "source": { "type": "regex", "value": ".wsl." } -
扩展
matchProfile可匹配的更多属性(如title); -
支持捕获组,例如:
{ "type": "profileMatch", "name": { "type": "regex", "value": "^ssh: (.*)" } }用于匹配所有
ssh:前缀的 profile,并用第一个捕获组填充条目名称——["ssh: foo", "ssh: bar"] 将展开为 "foo" 和 "bar" 两个条目。
九、文档更新记录与适用前提
- 2022 年 2 月更新:基于 issue #11326 与 #7774 的讨论,明确了需要一种简单方式自动汇集一整组 profile 用于菜单排序。尽管讨论焦点是"扩展能否自行提供这种定制",仍新增了
match语句(即本文的matchProfile),让用户可以自行过滤 profile。该能力最初被列为"未来考虑",最终被提前纳入。 - 适用前提:本文以当前仓库代码为准。
newTabMenu的键名、六种 type、folder 的inline/allowEmpty语义均可在 TerminalSettingsModel 源码中逐一对应;其中matchProfile的匹配在当前实现中已按 ICU 正则处理(见 MatchProfilesEntry.cpp),而规格文本描述的是"完整字符串比较"——使用时请以实际构建版本的实现行为为准,完整字符串在两种语义下均可安全匹配。 - 配套资料:规格所引用的"Unified keybindings and commands, and synthesized action names"(命令面板附录,动作 ID 与
openNewTabDropdown命名依据)见 规格文档;动作 ID 体系详见 doc/specs/#6899 - Action IDs/#6899 - Action IDs.md。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00