Skip to content

feat(plugin-launcher): grouped grid, trigger hand-off, and local app launching (#2085) - #2091

Merged
Tioit-Wang merged 14 commits into
mainfrom
feat/2085-plugin-launcher-grid
Nov 4, 2026
Merged

Tioit-Wang merged 14 commits into
mainfrom
feat/2085-plugin-launcher-grid

Conversation

@Tioit-Wang

@Tioit-Wang Tioit-Wang commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

这个 PR 做了什么

按 issue #2085 重写插件快捷面板(Alt+Space / ⌥+Space)。第一版把分组网格、正则唤起和本地应用做完;这一版按反馈把格子的呈现、固定方式、面板高度和改名能力重新定稿:

  1. feat(plugin-launcher): 统一格子为「图标 + 类型 + 名称」、正则命中占同一网格最前、图钉固定(上限 8)、面板内全部插件视图、内容自适应高度、真实大图标
  2. feat(i18n): 用类型标签替换命中原因文案,删除别名文案(9 个语言包)
  3. test(plugin-launcher): 覆盖新契约(触发置顶去重、固定上限、内容高度换算、图标预算与缓存)
  4. docs(plugins): 18-plugin-launcher(中英)、ADR 0339、E2E-268 同步
  5. Merge origin/main + docs(plugins): 与最新主干对齐(3 处 additive 冲突),并把 ADR 重新编号为 0343(0339 已被主干新增的 chat-file-refs ADR 占用)

需求对照

issue 要求 实现
每格 = 图标 + 名称,不要第二行 LauncherTileButton.tsx:图标统一 48px 圆角方形,名称独占整行,过长才省略;不再有命中原因 / 类型徽标 / 收到 1+1 小字
输入 1+1 时声明了正则的插件排第一个格子,且不与搜索命中做视觉区分 launcher-search.ts:正则整条命中仍是最高优先级,但进的是同一个 匹配结果 网格的第一批格子,外观与其它格一致;同一插件只出现一次
不显示 1+1 / 1+1 = 2 tile 不再渲染 payload;trigger.payload 仍随点击传给插件(launcherOpenPlugin)
不允许改插件或本地应用的名字 别名能力整体删除:launcher-store.ts 去掉 aliases 与 setLauncherAliases,LauncherItemMenu.tsx 删除,i18n 删除 5 个别名 key,LauncherMatchReason 去掉 "alias"
固定用图钉,固定区限 8 每格右上角图钉(悬停出现、已固定常显实心);LAUNCHER_PIN_LIMIT 24→8;已满时不再新增,底部提示「固定位已满(8/8)」并让固定区抖一下
「全部」在本面板内展开所有已安装插件并可滚动 最近使用标题右侧新增 全部插件 →;面板内单网格列出全部插件(最近使用优先,其余按目录序),复用结果区滚动,激活项 scrollIntoView;继续输入即回到搜索态,Esc 先收回该视图
面板高度随内容,不足 8 个不画占位 网格本身就是 8 列 minmax(0,1fr)、无占位格;新增 launcher-height.ts + pluginLauncher/contentHeight IPC,主进程按测量高度 setContentSize,下限降到 120(宽度默认 900、可拖 800–1280 并记忆)
筛选无命中要有空态 空态 + 新增「打开插件市场」按钮,经 pluginLauncher/openMarket 走应用菜单命令打开插件市场页
优先系统真实图标,取不到回退首字母色块 app.getFileIcon(..., { size: "large" }),预算 4 KiB→32 KiB,新增按 id 的跨扫描图标缓存;<img onError> 回退色块。插件仍用首字母色块(manifest.icon 宿主从不读取,见 ADR 0339)
正则带参唤起的安全与成本边界 保持原样:整条 trim 后匹配、输入 >256 字符不跑正则、pattern >200 字符不编译、host 拒绝嵌套量词、payload 一次性投递、两个通道都只接受 launcher 窗口的 webContents

| 插件与本地应用是否要视觉区分 | 不做区分:统一 48px 圆角方形,不画角标、不写「插件/应用」;来源只保留在悬停 title 里。这条推翻了 issue 原文的「一眼分得开」,issue 与文档已同步改写 |
| 面板宽度 | PLUGIN_LAUNCHER_DEFAULT_WIDTH 760→900、MIN_WIDTH 560→800(八列里每格仍放得下 48px 图标与可读名称) |

验证

  • node --test launcher 相关 7 个套件(launcher-search / launcher-store / plugin-launcher-state / plugin-launcher-height / plugin-launcher-window / local-apps / plugin-provider-catalog-ipc)→ 79 passed, 0 failed
  • packages/i18n vitest → 31 passed(9 语言包键集与插值一致性)
  • tsc -p apps/desktop --noEmit → 通过
  • biome lint → 136 files, 无问题
  • node docs/scripts/check-locales.mjs → 89 对 OK;node docs/scripts/check-docs.mjs → 612 页(仅 2 条既有 ADR 提示,与本次无关)
  • pnpm -r build(含 renderer/electron 构建)→ 通过
  • PR 上 6 项 CI 全部通过(head 6700105d2;Docs、JS、Rust fmt/lint/test、Rust Windows E2E、Electron E2E、base 校验)

未执行 / 已知限制(请 reviewer 留意)

  1. 没有跑 E2E:本机 pnpm shim 损坏(pnpm.CMD 指向缺失二进制),Electron 端无可用产物,pnpm test:e2e* 无法执行;E2E-268 已按模板更新并标为 Draft。请以 CI 结果为准。
  2. 没有做 UI 实机验证:高度自适应、图钉抖动、全量滚动都是按代码与单测推导,未在真实 Electron 窗口里点过。
  3. 市场页跳转是最小实现:复用应用菜单命令 openPluginMarket(主窗口聚焦 + 切到插件页 market 标签),没有新增 shell 导航契约;主窗口尚未 boot 时该命令会排队。
  4. 插件与本地应用不再可区分:这是刻意的取舍,换来名称整行可用;重名时只能点开才知道打开的是插件面板还是程序。若后续要恢复区分,建议做成悬停/页脚提示而不是回到每格占徽标。
  5. 用户手动拖拽窗口下边缘不会被即时纠正,只在下一次内容变化时被重置为内容高度(预期行为,但值得 reviewer 确认是否接受)。
  6. 存储里旧版本留下的 24 条固定记录读取时按 8 条截断;旧 aliases 字段静默忽略、不再写回。
  7. Linux .desktop 里 Exec 写裸命令(如 Exec=code %F)时,launchTarget 仍是裸命令,shell.openPath 会失败并提示「已卸载」。需要按 PATH 解析,属于本 PR 之外的后续工作,代码里已留下注释说明。

Refs #2085

CI

检查 结果
JS build / typecheck / lint / architecture / test pass (7m54s)
Rust host-core format / lint / test pass (2m13s)
Rust host-core Windows / transcript cache E2E pass (5m13s)
Trusted extensions Electron E2E pass (7m2s)
Docs checks pass (1m12s)
Head contains latest base pass

(期间修了三处真实失败:4 个裸样式值改为设计 token;plugin-ui-ipc 新增依赖补齐了 provider-catalog 测试的桩映射;ADR 0339 与主干新增的同号决策冲突,已重新编号为 0343。另有 2 项与本次无关的 Electron E2E 抖动,重跑后通过。)

The panel showed one flat list of seven plugins, forgot nothing, and could not be resized. It now has the shape the request describes: a search row, recently used (two rows of eight) and pinned (one row) when the input is empty, and a hit grid capped at three rows when it is not.

Matching moved into a pure module so the ordering rules are testable: a declared trigger beats every name match, then exact name and alias, prefixes, pinyin and its initials, and substrings. Every tile names why it matched, and plugins and applications are distinguishable by tile shape as well as by tag.

Recents, pins and aliases live under one storage key and are migrated from the previous panel's plugin-only history, so an upgrade keeps the order the user was used to.

The window is now resizable and remembers its size and position in the data directory, following the same main-process shell-state rule as the main window.
The panel only ever launched plugins. It now also lists the applications this machine actually has: Start Menu shortcuts on Windows, bundles on macOS, and desktop entries on Linux, cached for five minutes and re-scanned after that so an uninstalled application stops appearing.

The renderer never receives an executable path. It gets a display name, a source tag, a small icon and an opaque id, and the host resolves that id against its own scan and re-checks the target before opening it. The two new channels additionally refuse any sender that is not the launcher window, so the capability cannot be borrowed by another surface.

Scanning lives behind injected I/O, so the platform parsers, the id scheme, the cache and the removed-target path are all unit-tested without touching a real desktop.
A plugin can now declare contributes.launcherTriggers, so typing an expression offers the plugin that understands it before any name match, and pressing Enter hands that plugin the text it matched. The match is against the whole trimmed input, never a substring, and the host refuses patterns that nest a quantifier inside a repeated group, so a careless declaration cannot freeze the panel.

The payload travels with the panel open request and is delivered once, after the page has loaded, through a preload method that clears it on read. Only the plugin the user chose receives the text, and the payload is bounded so a paste cannot be forwarded verbatim.

Validation lives in its own submodule because the contributions validator was already at the size where a new block belongs beside the existing ones rather than inside them.
The rewritten panel labels each tile with why it matched, offers pin and alias actions, and reports how many matches the grid had to drop, so the catalog needs those strings in all nine shipped locales. Key parity and placeholder parity are enforced by the catalog test and by the per-locale EnglishCatalog type.
…tion

The plugin specs still described the launcher as merged into global search and had nothing about claiming input by pattern, so the new spec 18 records the layout, ranking, payload delivery, the local-application boundary and the ten checks a user can run. Spec 09 keeps its command-palette content and gains one paragraph separating the two surfaces.

The manifest schema gains contributes.launcherTriggers with the rules the host actually enforces, ADR 0339 records why application launching is confined to the launcher window and why the panel keeps no path, and the E2E plan gains a scenario for the grid, the trigger hand-off and the resized window.
The launcher's MRU list moved from plugin-launcher-history.ts into launcher-store.ts, so the neighbouring comment now names the file that exists instead of the one that was removed.
@Tioit-Wang

Copy link
Copy Markdown
Collaborator Author

补充一条环境说明(供 reviewer 判断哪些绿灯可信):

这个工作树按仓库策略复用父 checkout 的
ode_modules,其中安装的 @earendil-works/pi-ai 版本比 origin/main 期望的旧,因此:

  • sc -p apps/desktop 有 13 条错误,全部集中在 4 个与本 PR 无关的 provider 文件;
  • 全量
    ode --test apps/desktop/test/*.test.mjs 里会有大量与本 PR 无关的失败,典型报错是 ERR_PACKAGE_PATH_NOT_EXPORTED: Package subpath './models' is not defined by "exports" ... pi-ai/package.json(抽样 �gent-extensions.test.mjs 确认)。

本 PR 自己新增/改动的套件在同样的环境里是干净的:launcher-search、launcher-store、plugin-launcher-state、local-apps、plugin-launcher-window 共 62 项全绿;Rust 侧 cargo test -p host-core launcher 5 项、plugin 过滤 147 项全绿。

真正的全量门禁以 CI(干净安装)为准;本机无法执行 pnpm(shim 损坏)与 Electron E2E,已在 PR 描述里标注。

@Tioit-Wang

Copy link
Copy Markdown
Collaborator Author

还有一处没有在本 PR 里做完,先明确记下来,避免被当作已完成:

issue 第 3 节说“规则是插件自己声明的,我能在插件详情里看到它什么时候会跳出来,不想要就关掉”。当前状态:

  • 声明与匹配:已完成(contributes.launcherTriggers,host 校验 + 面板匹配)。
  • 用户级开关能力:已完成,launcher-store.ts 里的 setLauncherTriggerDisabled(pluginId, triggerId, …) 会让该正则不再抢输入。
  • 插件详情页展示触发规则 + 开关:未接线。 现在只有数据层与匹配层,主窗口的插件详情里还看不到这组规则,也没有可点的开关。

原因是主窗口插件详情目前拿不到 contributes.launcherTriggers(plugins.list 只返回 summary,不带 triggers),要在那里展示需要再扩一条只读的面板级摘要字段或专门的 IPC。这块更适合作为本 issue 的第二个 PR(“插件详情可见可控”),我在这里先如实标注,不假装已完成。

The style-token gate rejects raw typography and radius literals in the renderer, and the new launcher grid introduced four of them: a letter-spacing, a 50% radius for round application icons, and two line-height: 1 declarations. They now come from the tracking, radius and leading scales, which is also what keeps the icon shapes consistent with the rest of the shell.
@Tioit-Wang

Copy link
Copy Markdown
Collaborator Author

未完成事项清单(逐条对应 issue 需求)

这份清单取代我之前两条笼统的说明。每条都写明:issue 的哪一句 → 当前真实状态 → 差在哪里 → 为什么不在本 PR 里补。

A. issue 第 3 节:正则带参唤起

A1. 「我能在插件详情里看到它什么时候会跳出来,不想要就关掉」→ 只完成了数据层,UI 完全没做。

  • 已完成:contributes.launcherTriggers 的声明、host 校验(crates/host-core/src/plugins/validation/launcher_triggers.rs)、launcher 侧匹配;用户级开关的数据能力 setLauncherTriggerDisabled(pluginId, triggerId, disabled) 也在 launcher-store.ts 里。
  • 未完成:主窗口插件详情页没有渲染这些规则,也没有任何可点的开关。 用户现在既看不到"这个插件会在什么输入下跳出来",也无法在 UI 上关掉某一条规则(只能改 localStorage)。
  • 缺口原因:plugins.list 返回的 PluginSummary 不带 triggers,主窗口拿不到这份数据;要展示需要再加一个只读的摘要字段或专用 IPC(launcher 目录 IPC 是窗口级受限的,主窗口调不了)。
  • 归属:本 issue 的下一个 PR("插件详情可见可控")。

A2. 验收第 2 条里的 1+1 = 2 只做了一半。

  • 已完成:敲 1+1 时,声明了正则的插件排第一格;小字显示它将要收到的原文("收到 1+1");回车后插件确实拿到 1+1 这个 payload。
  • 未完成:小字不会显示 = 2。 issue 写的是 1+1 = 2,我们显示的是 1+1,计算结果需要插件自己算完再显示,属于插件面板内容。
  • 缺口原因:要在面板里显示结果,需要一条"命中后、打开前先问插件要 preview"的回调(host → 插件进程,带超时预算),这是新的执行路径,牵涉 plugin-runtime、host-process facade 和 SDK hook,风险与本 PR 主目标(布局 + 唤起 + 本地应用)不同,没有塞进来。
  • 影响:验收第 2 条按字面验收会不通过(第一格是计算器 ✅、= 2 ❌、回车后插件拿到 2 的前提是插件自己解析 payload)。

A3. 插件作者侧文档缺失。

  • 面板页读取 payload 的 API 是 window.pluginBridge.consumeLauncherPayload()(读一次即清空)。这在 spec 18 里写了,但没有写进面向插件作者的 API 文档(docs/spec/07-plugins/03-plugin-api.md),也没有 SDK 封装(pi.launcher.*)。面板页本来就不用 SDK,所以没有 SDK 封装是合理的,但作者文档这一块确实缺。

B. issue 第 4 节:启动本地应用

B1. Linux 上 Exec= 写裸命令的应用,点了会失败。

  • 现状:Exec=code %F 这类没有绝对路径的条目,launchTarget 就是 code,shell.openPath("code") 返回错误,面板提示"已卸载"。
  • 未完成:按 PATH 解析裸命令(需要新增一个注入的 resolveOnPath,并在解析不到时干脆不产出该条目)。
  • 缺口原因:本 PR 已冻结 local-apps.ts 的接口并配套 18 个测试,改动要连带改测试替身;为了不把最后阶段变成半成品,留作后续(代码里已有注释 + 一条测试记录当前行为)。Windows/macOS 不受影响。

B2. 别名入口比较隐蔽,没有独立管理界面。

  • issue 说"我能改别名",功能有:launcher 里把鼠标移到某个格子 → 右上角 ⋯ → 弹层里填别名(空格分隔、回车保存、可清除)。
  • 未完成:应用设置页/插件详情里没有别名管理入口。属于可用但不够好,不算缺失。

B3. 图标策略没有给用户选择。

  • 现状:优先系统真实图标(Windows/macOS 用 app.getFileIcon,Linux 用 .desktop 的 Icon),取不到就回退首字母色块。
  • 未完成:issue 里"系统真实图标 vs 首字母色块"是他"还不确定,想听建议"的问题,我做成了自动回退,没有做设置开关。

C. issue 第 2 节:布局细节差异

C1. 展开(46)→ / 全部 → 只在本面板内展开,没有"全量列表页"。

  • 现状:点 展开(N)→ 把最近使用从 2 排展开为全部(可滚动),再点变"收起";全部 → 把已固定的上限也放开。N 用的是真实总数。
  • 未完成:没有跳转到独立的全量视图/页面。

C2. 命中溢出的提示位置与设计稿不同。

  • 设计稿:"还有 N 项,继续输入可以缩小范围"单独一行。
  • 现状:以只读按钮形式放在命中分组的标题右侧(不可点)。
  • 差异:文案与语义一致,位置不同。

C3. 窄屏降列没有做。

  • issue 第 6 节明确把它列为"还不确定"的问题(1280×800 是否要自动降到 6 列)。
  • 现状:固定 8 列(grid-template-columns: repeat(8, minmax(0, 1fr))),最小窗口宽度 560 保证不塌陷。没有做自适应降列。

C4. 空位没有渲染占位方块。

  • issue 写"别的行也不空着,保持网格对齐"。
  • 现状:网格补齐行为由 CSS Grid 保证(同一行内的格子等宽对齐),但不足 8 个时不绘制占位方块。视觉上与设计稿不完全一致。

D. 验收 10 条的逐条状态

# 验收内容 状态
1 最近 2 排 + 固定 1 排、每排 8 列、窗口不撑破 ✅
2 敲 1+1 第一格是计算器且显示 1+1 = 2,回车后插件界面是 2 ⚠️ 部分:第一格 ✅、显示"收到 1+1"(无 = 2)、payload 正确交付 ✅
3 wx / weixin / 微信 出同一个应用 ✅
4 敲 js:插件"计算器"与应用"计算器"都在、来源不同 ✅(同名并存 + 来源标签 + 方形/圆形区分)
5 敲 pi:出现 PI-Desktop 与名称/拼音含 pi 的插件 ✅(取决于本机确实安装且被扫描到)
6 拖宽后 8 列仍对齐、文字不溢出、重开保持大小 ✅ 逻辑与持久化已实现并有单元/契约测试;未做真实 UI 点击验证(见 E)
7 命中超 24 不撑破窗口 + 提示 ✅
8 关掉/卸载插件后不再出现在最近、固定、命中 ✅(catalog 载入即 prune,启动前再校验)
9 500 字符乱码不卡死不误命中 ✅(有单测:超过 256 字符不跑正则,长度闸生效)
10 固定一个应用后重启仍在 ✅(localStorage 固定表,跨窗口 storage 事件同步)

E. 工程侧未完成/未验证

E1. 没有跑 Electron E2E。 本工作树按仓库策略复用父 checkout 的 node_modules,本机 pnpm shim 损坏、也没有 Electron 构建产物,pnpm test:e2e* 无法执行。E2E-268 已按模板写入 04-e2e-test-plan.md 并标为 Draft。所有依赖真实 UI 点击的验收(第 6 条、以及"拖宽后文字不溢出")只有源码契约测试与单元测试背书,没有一次真实点击记录。

E2. 本地 tsc / 全量 node --test 不可信。 复用依赖里的 @earendil-works/pi-ai 比基线旧:tsc 有 13 条错误(4 个无关 provider 文件),全量 node --test 里有大量 ERR_PACKAGE_PATH_NOT_EXPORTED: './models' 的失败(packages/host-runtime 的 launch-resolver.test.ts 也因同一原因挂掉)。本 PR 自己的 5 个套件在这些噪声之外是干净的。

E3. 没有做真实应用启动的冒烟。 launch() 走 shell.openPath,单元测试用替身覆盖了成功/失败/目标消失三条路径,但没有实际启动过任何一个真实应用(那会在 CI/开发机上真的弹窗口)。

F. 我认为需要你拍板的三个取舍

  1. 触发规则的开关放哪儿:插件详情页(本 issue 的语义)还是 launcher 自己的设置页?我倾向插件详情页,但那需要给 PluginSummary 增加 triggers 摘要。
  2. 1+1 = 2 是否值得为它引入"打开前 preview 回调"?这会新增 host→插件进程的执行路径。
  3. 窄屏是否真的要降 6 列?现在是固定 8 列 + 最小 560 宽。

The open-panel handler now decides whether an open carries launcher input, so plugin-ui-ipc imports the launcher module. The provider-catalog test loads that handler with a stubbed module map and refuses unknown dependencies; it now maps the launcher module to the real pure function, so the handler is still exercised as shipped instead of against a stand-in.
v_wyxgwang added 6 commits November 4, 2026 17:19
…to content

Every tile now reads icon + type + name. The second line used to explain why
something matched or what the plugin would receive, which made a trigger hit
look like a different kind of result and left the panel talking about `1+1 = 2`
instead of the plugin. A declared regex now simply puts its plugin in the first
cells of the same grid, and the payload still reaches only the plugin the user
picks.

The per-tile menu is gone: one pin button replaces it, the pinned row holds
eight and refuses with a footer notice plus a shake once it is full. "All
plugins" scrolls inside the panel, and the window height follows what was
rendered rather than a dragged height, so a short result no longer sits in an
empty box. Alias editing goes with it: plugin and application names are not
user-editable.

Local application icons now come from the platform at `large` size with a
32 KiB budget and an id-keyed cache, so a scan stops paying for the same icon
twice and tiles stop falling back to letters.
The panel no longer explains how an item matched, and it no longer offers
aliasing, so every `reason*` and alias string lost its call site. All nine
catalogs drop them and gain the two type labels plus the pin-limit notice and
the plugin-market button the new footer and empty state render.
…height

The old assertions described a detail line, alias matching and a 24-pin row,
all of which no longer exist. The suites now pin down the behaviour that
replaced them: a declared-trigger hit takes the first cells and is not listed
twice, pinning stops at eight, the content-height arithmetic rounds up and
floors at zero, and local icons are requested at `large` with a 32 KiB budget
and reused across scans.
…ized panel

The spec still promised a reason line under every tile, an alias editor, a
24-pin row and a window height the user drags. It now documents the icon +
type + name tile, a trigger hit leading the same grid, the eight-pin row that
refuses instead of evicting, the in-panel all-plugins view, the window that
follows measured content through its own IPC, and letter tiles for plugins.

The ADR records the icon budget change (large, 32 KiB, cached) and why a
plugin still gets a letter tile, and E2E-268 gains the scenarios that make the
new behaviour checkable by hand.
Brings the launcher work onto the current base. Three conflicts, all additive:
the IPC registrar imports keep both the launcher and the computer-use module,
the ADR index keeps 0339 next to 0340 and 0342, and E2E-268 keeps its scenario
alongside the new computer-use section.
`main` promoted its own 0339 for chat file references while this branch was
open, so the launcher ADR collided with it and the docs gate rejected two
duplicate decision ids. The launcher record moves to the next free id and every
reference to it follows.
@Tioit-Wang
Tioit-Wang merged commit 2a7cd79 into main Nov 4, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant