Merge branch 'main' into codex/tab-menu-korean-i18n

This commit is contained in:
Kuddev authored and GitHub committed 2026-09-23 23:32:27 +08:00
commit 0d723f30b9
39 files changed
+2454 -238

No files matched your search

+140 -39
View File
@@ -1,114 +1,215 @@
name: Bug Report
description: Report a reproducible problem in Nebula
name: Bug Report / 问题反馈
description: Report a reproducible problem in Pebrel / 反馈一个可复现的问题
title: "[Bug] "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Please search existing issues first and include enough detail for someone else to reproduce the problem.
Thanks for taking the time to report a bug. A report we can reproduce gets fixed in the next release; one we cannot reproduce is asked for details and waits.
感谢反馈。**能复现的问题会进下个版本;说不清的问题只能先挂起等补充。** 下拉选项会自动转成标签。
- type: dropdown
id: platform
attributes:
label: Operating system
label: Operating system / 操作系统
options:
- Windows
- Windows (WSL)
- macOS
- Linux
- Other
- Other / 其他
validations:
required: true
- type: input
id: os_version
attributes:
label: OS version and display / 系统版本与显示
description: Windows 11 23H2 / macOS 26.0 / Ubuntu 24.04; DPI scaling and monitor count if display-related. / 系统版本;渲染问题请附缩放比例与显示器数量。
placeholder: "Windows 11 23H2, 150% scaling, 2 monitors"
validations:
required: true
- type: input
id: version
attributes:
label: Nebula version
description: Find it in Settings > Home, or on the latest release page.
placeholder: "e.g. 1.0.0"
label: Pebrel version / 版本号
description: Settings > Home, or the release page. / 设置 > 主页,或发布页。
placeholder: "e.g. 1.9.0"
validations:
required: true
- type: dropdown
id: install_source
attributes:
label: How did you install Nebula?
label: How did you install Pebrel? / 安装方式
options:
- GitHub Release (.msi / .exe / portable archive)
- Built from source (cargo build / cargo run)
- Other
- GitHub Release (.exe / .zip / .dmg / .AppImage / .deb)
- Built from source / 源码构建 (cargo build)
- Other / 其他
validations:
required: true
- type: dropdown
id: shell
attributes:
label: Shell in the affected pane / 出问题的面板用的 Shell
multiple: true
options:
- PowerShell 7 (pwsh)
- Windows PowerShell 5.1
- cmd
- WSL (bash / zsh inside WSL)
- Git Bash / MSYS2
- zsh
- bash
- fish
- nushell
- Not sure / 不确定
validations:
required: true
- type: dropdown
id: program
attributes:
label: Program running in the pane / 面板里正在跑的程序
description: What was running when it happened? / 出问题时面板里跑着什么?
multiple: true
options:
- Nothing, plain shell prompt / 什么都没跑
- Claude Code
- Codex CLI
- Pi
- Gemini CLI / other AI agent — 其他 AI agent
- Terminal multiplexer (screen etc.) — 终端复用器
- vim / nvim / other full-screen TUI — 全屏 TUI
- ssh client / remote shell — 远程 shell
- A long-running command (build, ping, adb…) — 长时间运行的命令
- Other / 其他
validations:
required: true
- type: dropdown
id: connection
attributes:
label: Connection type / 连接方式
options:
- Local terminal / 本地终端
- SSH via Pebrel host list / Pebrel 内 SSH 主机
- SSH via command line (ssh user@host) / 手动 ssh 命令
- WSL
- Other / 其他
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
label: Affected area / 影响范围
description: Pick every area that applies. / 可多选。
multiple: true
options:
- Local terminal / shell
- SSH connection / authentication
- SFTP / file tree
- Tabs / splits / sessions
- AI assistant / providers
- Settings / backup / sync
- UI / layout / rendering
- Startup / crash
- Other
- Terminal rendering / scrolling / selection / garbled text — 终端渲染、滚动、选区、乱码
- Shell startup / PATH / start directory / context menu — Shell 启动、环境变量、起始目录、右键菜单
- SSH / proxy / jump host — SSH、代理、跳板
- File tree / drag-and-drop upload — 文件树、拖拽上传
- Tabs / splits / session restore / window size — 标签页、分屏、会话恢复、窗口尺寸
- AI agent (Claude Code / Codex / Pi / OMP) — AI Agent 集成与状态
- Markdown / LaTeX / image reader — 阅读页与内联渲染
- Settings / theme / layout / blur — 设置页、主题、布局、模糊背景
- Keyboard / IME / mouse / shortcuts / clipboard — 键盘、输入法、鼠标、快捷键、剪贴板
- Git / SVN
- Startup crash / hang / cannot exit — 启动崩溃、卡死、无法退出
- Performance / lag — 性能、卡顿
- Installer / update / packaging — 安装包、更新、打包
- Other / 其他
validations:
required: true
- type: dropdown
id: regression
attributes:
label: Did it work in a previous version? / 旧版本是否正常?
options:
- Not sure / 不确定
- Yes, it worked before / 旧版本正常,新版本坏了
- No, it never worked / 一直如此
validations:
required: true
- type: dropdown
id: reproducibility
attributes:
label: Can you reproduce it?
label: Can you reproduce it? / 复现概率
options:
- Always (100%)
- Often (>50%)
- Sometimes
- Once / not sure
- Always (100%) / 必现
- Often (>50%) / 经常
- Sometimes / 偶尔
- Once / not sure / 仅一次
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Use numbered steps and include the shell or connection type when relevant.
label: Steps to reproduce / 复现步骤
description: |
Numbered, from a fresh launch, ending at the exact moment it goes wrong. "It's laggy" or "it doesn't work" cannot be acted on.
从启动开始按序编号,写到出错的那一步为止。"很卡"、"不好用"这种一句话我们没法处理。
placeholder: |
1. Open Nebula
2. Create or connect to ...
3. ...
1. Launch Pebrel, open a new PowerShell 7 tab
2. Run `codex` and ask it to ...
3. Split the pane with Ctrl+Shift+D
4. Output overlaps the input box at the bottom (see screenshot)
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
label: Expected behavior / 期望结果
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
label: Actual behavior / 实际结果
description: What you see instead, including any error text verbatim. / 实际看到什么,错误文字原样贴。
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / screenshots
description: Include relevant lines or screenshots. Remove passwords, private keys, tokens, host addresses, and other secrets.
label: Logs / screenshots / 日志或截图
description: |
Required. Screenshot or short recording for anything visual; log lines for crashes, hangs and connection problems. Remove passwords, keys, tokens and host addresses first.
必填。显示类问题贴截图或短录屏;崩溃、卡死、连接类问题贴日志。请先删掉密码、私钥、token、主机地址。
Logs / 日志位置:
- Windows: `%APPDATA%\Pebrel\pebrel_debug.log` and `%TEMP%\Pebrel-<pid>.log`
- macOS: `~/Library/Application Support/Pebrel/pebrel_debug.log` and `$TMPDIR/Pebrel-<pid>.log`
- Linux: `~/.config/pebrel/pebrel_debug.log` and `/tmp/Pebrel-<pid>.log`
placeholder: Paste redacted logs or drag screenshots here.
validations:
required: true
- type: textarea
id: settings
attributes:
label: Non-default settings / 改过的设置
description: Theme, font, transparency/blur, GPU, shell profile, hooks — anything changed from defaults. Attach settings.txt if unsure. / 改过的主题、字体、透明/模糊、GPU、Shell 配置、hooks;不确定就贴 settings.txt。
placeholder: "Acrylic blur on, opacity 85%, font JetBrains Mono 13"
- type: checkboxes
id: checklist
attributes:
label: Before submitting
label: Before submitting / 提交前确认
options:
- label: I searched existing issues and did not find a duplicate
- label: I searched existing issues and did not find a duplicate / 已搜索,无重复
required: true
- label: I removed passwords, private keys, tokens, and other secrets from this report
- label: I tested with the latest release / 已用最新版本试过
required: true
- label: I removed passwords, private keys, tokens and other secrets / 已删除敏感信息
required: true
+9 -6
View File
@@ -1,8 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Existing issues
url: https://github.com/Kuddev/nebula/issues
about: Search existing reports before opening a new issue.
- name: Latest release
url: https://github.com/Kuddev/nebula/releases/latest
about: Check whether the problem is already fixed in the latest version.
- name: Existing issues / 已有 issue
url: https://github.com/Kuddev/pebrel/issues
about: Search existing reports before opening a new issue. / 先搜一下是否已有人反馈。
- name: Latest release / 最新版本
url: https://github.com/Kuddev/pebrel/releases/latest
about: Check whether the problem is already fixed in the latest version. / 看看最新版是否已修复。
- name: Discord
url: https://discord.gg/VFn4rcxmhn
about: Quick questions and discussion. / 快速提问与交流。
+41 -24
View File
@@ -1,5 +1,5 @@
name: Feature Request
description: Suggest an improvement or new capability for Nebula
name: Feature Request / 功能建议
description: Suggest an improvement or new capability for Pebrel / 提出改进或新功能
title: "[Feature] "
labels: ["enhancement", "triage"]
body:
@@ -7,12 +7,13 @@ body:
attributes:
value: |
Describe the workflow problem first, then the change you want. Concrete examples make the request easier to evaluate.
先说现在哪里不顺手,再说希望怎么改。具体的使用场景最有说服力。
- type: textarea
id: problem
attributes:
label: Problem / pain point
description: What is hard, missing, or repetitive today?
label: Problem / pain point — 现在的痛点
description: What is hard, missing, or repetitive today? / 什么事难做、缺失、或者每天重复?
placeholder: When I work with multiple terminals or hosts, I cannot ...
validations:
required: true
@@ -20,50 +21,66 @@ body:
- type: textarea
id: solution
attributes:
label: Proposed solution
description: What would you like Nebula to do?
label: Proposed solution — 希望的做法
description: What would you like Pebrel to do? / 希望 Pebrel 怎么做?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: List workarounds, other tools, or designs you considered.
validations:
required: true
label: Alternatives considered — 现在的替代办法
description: Workarounds, other tools, or designs you considered. / 目前怎么绕过去的,别的终端怎么做的。
- type: dropdown
id: platform
attributes:
label: Platform / 平台
description: Leave empty if it applies everywhere. / 全平台通用可不选。
multiple: true
options:
- Windows
- Windows (WSL)
- macOS
- Linux
- type: dropdown
id: area
attributes:
label: Related area
label: Related area / 相关范围
multiple: true
options:
- Local terminal / shell
- SSH / SFTP
- Tabs / splits / sessions
- AI assistant / providers
- Settings / backup / sync
- UI / UX
- Other
- Terminal rendering / scrolling / selection — 终端渲染、滚动、选区
- Shell startup / PATH / start directory / context menu — Shell 启动、环境变量、起始目录、右键菜单
- SSH / proxy / jump host — SSH、代理、跳板
- File tree / drag-and-drop upload — 文件树、拖拽上传
- Tabs / splits / session restore / window size — 标签页、分屏、会话恢复、窗口尺寸
- AI agent (Claude Code / Codex / Pi / OMP) — AI Agent 集成与状态
- Markdown / LaTeX / image reader — 阅读页与内联渲染
- Settings / theme / layout / blur — 设置页、主题、布局、模糊背景
- Keyboard / IME / mouse / shortcuts / clipboard — 键盘、输入法、鼠标、快捷键、剪贴板
- Git / SVN
- Performance / lag — 性能、卡顿
- Installer / update / packaging — 安装包、更新、打包
- Other / 其他
validations:
required: true
- type: dropdown
id: priority
attributes:
label: How important is this to you?
label: How important is this to you? / 对你有多重要
options:
- Nice to have
- Would improve my daily workflow
- Blocking / critical for my use case
- Nice to have / 有更好
- Would improve my daily workflow / 每天都会用到
- Blocking / critical for my use case / 没有就没法用
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: Before submitting
label: Before submitting / 提交前确认
options:
- label: I searched existing issues for similar requests
- label: I searched existing issues for similar requests / 已搜索,无重复
required: true
+154
View File
@@ -0,0 +1,154 @@
name: Issue labels
# Turns the dropdown answers of the issue forms (.github/ISSUE_TEMPLATE/*.yml)
# into labels and flags bug reports that are too thin to act on. Labels are
# only added; maintainers' manual labels are never removed.
on:
issues:
types: [opened, edited]
permissions:
issues: write
concurrency:
group: issue-labels-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
label-from-form:
name: label-from-form
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Map form answers to labels
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const body = issue.body || "";
// Issue forms render every field as "### <label>\n\n<value>";
// multi-select dropdowns join their values with ", ".
const fields = {};
const re = /^### (.+?)\s*\n+([\s\S]*?)(?=^### |\s*$(?![\s\S]))/gm;
for (const m of body.matchAll(re)) {
const value = m[2].trim();
if (value && value !== "_No response_") fields[m[1].trim()] = value;
}
const field = (prefix) => {
const key = Object.keys(fields).find((k) => k.startsWith(prefix));
return key ? fields[key] : "";
};
const answers = (prefix) => {
const value = field(prefix);
return value ? value.split(", ").map((s) => s.trim()) : [];
};
// Match on the English lead of each option so wording tweaks in
// the Chinese half never silently break the mapping.
const OS = [
["Windows (WSL)", ["os: windows", "os: wsl"]],
["Windows", ["os: windows"]],
["macOS", ["os: macos"]],
["Linux", ["os: linux"]],
];
const AREA = [
["Terminal rendering", ["area: terminal"]],
["Shell startup", ["area: shell"]],
["SSH", ["area: ssh"]],
["File tree", ["area: files"]],
["Tabs / splits", ["area: session"]],
["AI agent", ["area: agents"]],
["Markdown", ["area: reader"]],
["Settings", ["area: ui"]],
["Keyboard", ["area: input"]],
["Git", ["area: git"]],
["Startup crash", ["crash"]],
["Performance", ["performance"]],
["Installer", ["area: ci-release"]],
];
const PROGRAM = [
["Claude Code", ["area: agents"]],
["Codex", ["area: agents"]],
["Pi", ["area: agents"]],
["Gemini", ["area: agents"]],
];
const CONNECTION = [
["SSH", ["area: ssh"]],
["WSL", ["os: wsl"]],
];
const SHELL = [["WSL", ["os: wsl"]]];
const pick = (values, table) => {
const out = [];
for (const v of values) {
const hit = table.find(([lead]) => v.startsWith(lead));
if (hit) out.push(...hit[1]);
}
return out;
};
const labels = new Set([
...pick([...answers("Operating system"), ...answers("Platform")], OS),
...pick([...answers("Affected area"), ...answers("Related area")], AREA),
...pick(answers("Program running in the pane"), PROGRAM),
...pick(answers("Connection type"), CONNECTION),
...pick(answers("Shell in the affected pane"), SHELL),
]);
if (answers("Did it work in a previous version").some((v) => v.startsWith("Yes"))) {
labels.add("regression");
}
// Thin bug reports: no usable steps or no evidence. Ask once, on
// open; an edit that fills the gaps clears the label.
const existing = new Set(issue.labels.map((l) => l.name));
const isBug = existing.has("bug");
const steps = field("Steps to reproduce");
const logs = field("Logs / screenshots");
const missing = [];
// CJK carries more per character; weigh it double. Numbered
// multi-line steps count as an attempt regardless of length.
const compact = steps.replace(/\s+/g, "");
const weight = compact.length + (compact.match(/[ -鿿豈-﫿]/g) || []).length;
const numbered = (steps.match(/^\s*\d+[.、)]/gm) || []).length >= 2;
if (isBug && weight < 60 && !numbered) {
missing.push("**Steps to reproduce / 复现步骤**: numbered steps from launch to the failing moment / 从启动开始按序写到出错那一步");
}
if (isBug && !logs) {
missing.push("**Logs / screenshots / 日志或截图**: a screenshot for visual issues, `pebrel_debug.log` for crashes, hangs and connection problems / 显示类问题贴截图,崩溃卡死连接类贴日志");
}
if (isBug && !/\d+\.\d+/.test(field("Pebrel version"))) {
missing.push("**Pebrel version / 版本号**: e.g. `1.9.0` (Settings > Home)");
}
const toAdd = [...labels].filter((l) => !existing.has(l));
if (missing.length) {
if (!existing.has("needs-info")) toAdd.push("needs-info");
if (context.payload.action === "opened") {
await github.rest.issues.createComment({
...context.repo,
issue_number: issue.number,
body: [
"Thanks for the report. It is missing details we need before anyone can reproduce it, so it is parked as `needs-info`. Please edit the issue and fill in:",
"感谢反馈。目前缺少复现所需的信息,已标记 `needs-info`。请**编辑 issue** 补齐以下内容,补齐后标签会自动移除:",
"",
...missing.map((m) => `- ${m}`),
"",
"Reports without these are closed after 14 days of silence. / 14 天无补充将关闭。",
].join("\n"),
});
}
} else if (isBug && existing.has("needs-info") && context.payload.action === "edited") {
await github.rest.issues.removeLabel({
...context.repo, issue_number: issue.number, name: "needs-info",
}).catch(() => {});
}
core.info(`form labels: ${[...labels].join(", ") || "(none)"}; adding: ${toAdd.join(", ") || "(none)"}; missing: ${missing.length}`);
if (toAdd.length) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issue.number,
labels: toAdd,
});
}
+51 -8
View File
@@ -11,7 +11,9 @@ on:
push:
branches: [main]
# Include docs, catalogs, assets and workflow edits: every PR gets test results.
# ready_for_review starts the scarce-runner jobs that draft pushes skip.
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
merge_group:
concurrency:
@@ -29,13 +31,50 @@ jobs:
with:
runtime_run: ${{ inputs.windows_diagnostics_run }}
# rustfmt only for now. clippy joins once the existing warning backlog is
# cleared; several crates deny lints, so it cannot run advisory.
lint:
if: ${{ inputs.windows_diagnostics_run == '' }}
name: lint
outputs:
native_matrix: ${{ steps.matrix.outputs.native_matrix }}
release_matrix: ${{ steps.matrix.outputs.release_matrix }}
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.97.1"
components: rustfmt
- name: rustfmt
run: cargo fmt --all -- --check
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Plan native validation
id: matrix
shell: bash
run: |
python scripts/ci_plan.py \
--event-name "$GITHUB_EVENT_NAME" \
--event-path "$GITHUB_EVENT_PATH" \
--output "$GITHUB_OUTPUT"
# The required lint job validates the matrix before heavy runners are
# requested. Draft PRs omit deferred platforms entirely; ready PRs and
# release callers require the full matrix. Deferred checks remain missing
# on drafts instead of being reported as successful tests.
native-tests:
needs: lint
if: ${{ inputs.windows_diagnostics_run == '' }}
name: Tests (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-2022, windows-11-arm, macos-26, macos-26-intel]
include: ${{ fromJSON(needs.lint.outputs.native_matrix) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 120
env:
@@ -46,12 +85,13 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install native dependencies
if: runner.os == 'Linux'
if: ${{ runner.os == 'Linux' }}
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
@@ -62,7 +102,7 @@ jobs:
libx11-xcb-dev libxcb1-dev libxcb-render0-dev \
libxcb-shape0-dev libxcb-xfixes0-dev libxcb-xinput-dev libxcb-xkb-dev libxi-dev
- name: Require the AppKit SDK used by this PR
if: runner.os == 'macOS'
if: ${{ runner.os == 'macOS' }}
run: |
sdk_version="$(xcrun --sdk macosx --show-sdk-version)"
test "${sdk_version%%.*}" -ge 26
@@ -70,7 +110,7 @@ jobs:
with:
toolchain: "1.97.1"
- name: Require a native Windows ARM64 compiler
if: matrix.os == 'windows-11-arm'
if: ${{ matrix.os == 'windows-11-arm' }}
shell: pwsh
run: |
if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'Arm64') {
@@ -90,7 +130,7 @@ jobs:
${{ runner.os != 'macOS' && 'target/release' || '' }}
legacy-key: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'Linux' && 'linux' || 'macos' }}
- name: Prepare pinned Windows console runtime
if: runner.os == 'Windows'
if: ${{ runner.os == 'Windows' }}
shell: pwsh
run: |
$architecture = if ('${{ runner.arch }}' -eq 'ARM64') { 'arm64' } else { 'x64' }
@@ -116,7 +156,7 @@ jobs:
${{ runner.os != 'macOS' && 'target/release' || '' }}
key: ${{ steps.rust-cache.outputs.cache-key }}
- name: Upload native compilation timings
if: always()
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: native-timings-${{ matrix.os }}
@@ -130,12 +170,13 @@ jobs:
# 34856491552. Both jobs remain part of this reusable workflow's result, so
# the stable release aggregation still requires every check to succeed.
macos-release-check:
needs: lint
if: ${{ inputs.windows_diagnostics_run == '' }}
name: Release workspace (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-26, macos-26-intel]
include: ${{ fromJSON(needs.lint.outputs.release_matrix) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 120
env:
@@ -145,6 +186,8 @@ jobs:
RUST_TOOLCHAIN: "1.97.1"
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: "3.13"
@@ -170,7 +213,7 @@ jobs:
path: target/release
key: ${{ steps.rust-cache.outputs.cache-key }}
- name: Upload release check compilation timings
if: always()
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: release-check-timings-${{ matrix.os }}
+81
View File
@@ -0,0 +1,81 @@
name: PR size
# Labels every pull request by the number of changed source lines and fails
# oversized ones so they get split before review. Generated and documentation
# files do not count. Maintainers bypass the limit with the `size-exempt`
# label (a release integration branch is the usual case).
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
# Required checks must also report inside the merge queue; the size was
# already enforced on the pull request, so this is a pass-through.
merge_group:
permissions:
pull-requests: write
concurrency:
group: pr-size-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
pr-size:
name: pr-size
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
const pr = context.payload.pull_request;
const LIMIT = 1500;
const IGNORED = [
/^Cargo\.lock$/, /(^|\/)package-lock\.json$/, /(^|\/)yarn\.lock$/,
/^docs\//, /\.md$/, /\.snap$/, /\.svg$/, /\.png$/, /\.ico$/,
/^assets\//, /^locales\//, /\.lock$/,
];
const files = await github.paginate(github.rest.pulls.listFiles, {
...context.repo, pull_number: pr.number, per_page: 100,
});
let counted = 0, ignored = 0;
for (const f of files) {
const lines = f.additions + f.deletions;
if (IGNORED.some((re) => re.test(f.filename))) ignored += lines; else counted += lines;
}
const size = counted <= 100 ? "S" : counted <= 500 ? "M" : counted <= LIMIT ? "L" : "XL";
const labels = new Set(pr.labels.map((l) => l.name));
const wanted = `size/${size}`;
for (const l of labels) {
if (l.startsWith("size/") && l !== wanted) {
await github.rest.issues.removeLabel({ ...context.repo, issue_number: pr.number, name: l }).catch(() => {});
}
}
if (!labels.has(wanted)) {
await github.rest.issues.addLabels({ ...context.repo, issue_number: pr.number, labels: [wanted] });
}
core.info(`counted ${counted} lines (${ignored} ignored) in ${files.length} files -> ${wanted}`);
if (size !== "XL" || labels.has("size-exempt")) return;
const marker = "<!-- pr-size-guard -->";
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo, issue_number: pr.number, per_page: 100,
});
if (!comments.some((c) => c.body && c.body.includes(marker))) {
await github.rest.issues.createComment({
...context.repo, issue_number: pr.number,
body: [
marker,
`This pull request changes **${counted}** source lines (limit ${LIMIT}; docs, lockfiles and assets excluded). Changes this large cannot be reviewed reliably, so please split it into independent pull requests — one behaviour change each, with its own tests.`,
"",
`本 PR 改动 **${counted}** 行源码(上限 ${LIMIT},不计文档、lockfile、资源)。这么大的改动无法可靠审查,请拆成多个相互独立的 PR,每个只做一件事并附测试。`,
"",
"Maintainers can add the `size-exempt` label for integration branches. / 维护者可对集成分支加 `size-exempt` 放行。",
].join("\n"),
});
}
core.setFailed(`${counted} changed source lines exceed the ${LIMIT}-line limit; split the pull request or add size-exempt.`);
+1 -20
View File
@@ -4,26 +4,7 @@ run-name: >-
Cross-platform Preview (${{ inputs.preview_id || format('run-{0}', github.run_number) }})
on:
pull_request:
# Native tests cover every PR; package only when package inputs change.
paths:
- "**/Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- "nebula_app/build.rs"
- "nebula_app/build/**"
- "nebula_app/res/**"
- "nebula_app/windows/**"
- "assets/**"
- "packaging/**"
- "scripts/package*"
- "scripts/build-*"
- "scripts/installer*"
- "scripts/prepare-windows-runtime.ps1"
- "scripts/preview_release.py"
- "scripts/conformance/**"
- "scripts/tests/test_preview_release.py"
- ".github/workflows/preview-packages.yml"
# Pull requests use Full native tests; packages are built after merge or on request.
push:
branches: [main]
# Native tests cover every PR; package only when package inputs change.
+20 -6
View File
@@ -74,6 +74,8 @@ raise any existing tracked private artifact for an explicit maintainer decision.
`architecture/notes/`; ordinary fixes do not create a note.
2. Keep one conceptual change per PR. A necessary extraction and its behavior
tests may accompany the feature; unrelated rewrites and formatting may not.
The `pr-size` check fails above 1500 changed source lines (docs, lockfiles
and assets excluded); split instead of asking for an exemption.
3. Put shared rules in their existing authority. UI modules adapt those rules;
they must not fork persistence, state transitions or domain behavior.
4. Add regression tests that fail for the defect, and test error/cancellation
@@ -105,9 +107,11 @@ cargo check -p nebula --bin pebrel --features gpui-shell --tests --locked
`Full native tests` runs on every PR and merge-group update, and on `main` pushes.
It tests the full workspace on Linux, Windows x64 / ARM64 and both macOS
architectures. New commits cancel obsolete PR runs. Package validation has its own
input filters and can also be dispatched manually; a package job does not replace
the native test suite. These triggers do not configure required status checks.
architectures. New commits cancel obsolete PR runs. Pull requests run tests and
compile checks without building distribution packages. Package validation runs
after matching changes reach `main`, or by explicit manual dispatch; a package
job does not replace the native test suite. These triggers do not configure
required status checks.
GitHub may show **Waiting for approval** for a first-time fork contributor. A
maintainer must inspect the submitted changes and approve that workflow run from
@@ -122,9 +126,17 @@ compile check into a claim that UI tests or a packaged application were run.
## Review and enforcement
`architecture-contracts` is the stable PR job name. Maintainers must enable it as
a required check and require Code Owner approval in the target branch ruleset;
see the [activation checklist](docs/project-constraints.md#server-side-activation).
`architecture-contracts`, `lint`, `pr-size`, the five
`Tests (<os>)` jobs and both `Release workspace (<os>)` jobs are required checks
on `main`, together with Code Owner approval; see the
[activation checklist](docs/project-constraints.md#server-side-activation).
Open pull requests as drafts while iterating: drafts run Linux, Windows x64 and
Apple Silicon tests plus the Apple Silicon release-profile compile check. The
required lint job validates the event and selects the matrix before requesting
platform runners. Drafts omit Intel Mac and Windows ARM64 jobs entirely, so their
required contexts may be missing until the PR is marked ready. Ready PRs run the
full matrix; every required check must succeed before merging. The ready event
still reruns core validation rather than reusing an earlier check conclusion.
Local hooks are convenient, but bypassable; they are not the enforcement boundary.
Submitting a workflow or `CODEOWNERS` file does not configure server-side rules.
@@ -137,6 +149,8 @@ feature work; there is no routine `--skip-architecture` option.
## 中文摘要
- 先读架构图、工程合同和决策记录;按职责拆分,不按行号切片。
- 一个 PR 只做一件事;改动超过 1500 行源码(不计文档、lockfile、资源)`pr-size` 会失败,请拆分。
- Draft PR 先运行必需的格式检查和矩阵规划,再创建 Linux / Windows x64 / Apple Silicon 测试及 Apple Silicon release 编译检查;不创建 Intel Mac / Windows ARM64 任务。Ready 后运行完整矩阵,十项必需检查全绿才能合并。
- 2000 行是现有仓库的防灾上限,800 行只提示审查,不是“大厂标准”。
- 普通功能 PR 不得增加存量债务;有问题的规则可以修订,但要有反例、测试和维护者审批。
- 新增核心抽象、依赖方向、持久化或线程模型改变要先说明设计,不强迫每个小修复写 ADR。
@@ -0,0 +1,64 @@
# Color capabilities belong to the local child environment
## Status
Implemented; pending review.
## Context
Windows users report that Codex lacks its sparse composer stars and user-message
backgrounds in Pebrel while they appear in the reference terminal. The same distinction
must work across themes without identifying message roles from screen text.
## Evidence
GPUI `local_options` refreshes a complete Windows environment before `spawn`
calls `tty::setup_env()`. The environment cache preserves its earlier snapshot;
changing the parent environment afterward cannot insert `COLORTERM` into the
already complete child environment. An Explorer launch need not have terminal
capability variables inherited from another terminal.
Codex's `rust-v0.154.0` `tui/src/terminal_palette.rs` returns the default color
for ANSI-16/unknown color levels. `style.rs` uses that result for user-message
backgrounds. `bottom_pane/chat_composer/sparkle.rs` requires truecolor and queried
foreground/background colors. This explains a shared failure path; settings,
model choice and Codex version still control whether its animation runs.
## Decision
After refreshing a local Windows environment, fill missing `TERM` with
`xterm-256color` and missing `COLORTERM` with `truecolor`. Preserve explicit
case-insensitive overrides and `NO_COLOR`/`FORCE_COLOR`. Forward `COLORTERM` through the existing WSL environment allowlist, preserving
explicit forwarding flags. Keep the actual Pebrel terminal identity. Existing OSC color-query and rendering paths remain authoritative.
## Rejected alternatives
- Pretending to be the reference terminal through `WT_SESSION` misstates identity.
- Recoloring arbitrary terminal rows as user messages would invent application
semantics and damage ordinary command output.
- Hardcoding a Nord-only fix leaves other themes and applications affected.
## Consequences
New local Windows panes advertise implemented capabilities independently of their
launching parent. Already running shells retain their inherited environment.
Providers can still intentionally suppress colors or animations. The two-second
Hook lifetime fix is unrelated to this environment declaration.
## Validation
Eight platform-environment tests pass, including an actual child launched with
an explicitly complete environment. VT replay preserves message surfaces and
Braille stars for all fifteen built-in themes, plus all 225 theme-switch pairs.
A native Debian control returned no `COLORTERM` without forwarding and
`truecolor` with it. These are capability/rendering tests, not a full live Codex
animation acceptance.
## Supersedes
None.
## Revisit when
Terminal capability defaults move into a shared per-child environment builder,
or the Windows environment refresh no longer creates a complete snapshot.
@@ -0,0 +1,63 @@
# Bound the lifetime of one-shot Hook forwarding
## Status
Implemented; pending review.
## Context
[Issue #258](https://github.com/Kuddev/pebrel/issues/258) reports an update that
first failed because `pebrel-hook.exe` remained in use after the application
closed, followed by a successful same-version repair incorrectly marked failed.
The helper must finish even when its caller or notification receiver stalls.
## Evidence
`nebula_hook/src/main.rs` drained stdin through `read_to_end` and wrote the
notification through synchronous `write_all`. Neither had a deadline. A caller
retaining stdin can prevent EOF; a receiver that stops reading can block a write.
Both conditions are reproduced by actual child processes in
[`lifetime.rs`](../../../nebula_hook/tests/lifetime.rs).
## Decision
Run the existing forwarding operation on one process-owned worker. The main
thread waits up to two seconds, then returns normally. Returning from this
one-shot process releases its worker and OS handles without leaving a resident
thread. This bounds stdin, oversize-input draining and notification I/O together.
No new dependency, background application service or platform thread is added.
The user's chained notifier is launched independently after forwarding/waiting;
it is neither joined nor terminated. Cursor's submission-allow response remains
on the main path, including timeout. All provider-visible exits remain successful.
## Rejected alternatives
- Timing only stdin leaves a blocked pipe write alive.
- Joining the worker after timeout defeats the bound.
- Terminating every process named `pebrel-hook.exe` can affect another installation
or invocation. This change retires only the helper's own process.
- Skipping stdin entirely breaks providers which stream their payload into it.
## Consequences
A stalled or unusually slow invocation can lose its best-effort notification.
The forwarding wait is bounded; this is not a universal process startup latency
guarantee. Already running binaries from an older installation do not acquire
the new deadline and may still require the user to close the owning CLI/restart.
## Validation
`cargo test --locked -p nebula_hook`: seven unit tests and five actual-process
tests passed on Windows, covering retained stdin, a connected non-reading named
pipe, normal completion, Cursor continuation and the original chained notifier.
Non-Windows integration paths still require their native CI run.
## Supersedes
None.
## Revisit when
Providers require a longer documented delivery window or the transport becomes
cancellable without a per-invocation worker.
@@ -0,0 +1,67 @@
# Keep automatic package builds out of pull-request checks
## Status
Accepted by the maintainer on 2026-09-23: pull requests run tests and compile
checks; distribution packages are built after merge or on explicit request.
## Context
Preview packaging subscribed to pull requests that changed package inputs.
A Windows installer-menu change therefore started Linux and both macOS package
builds alongside the independent native test matrix. These workflows requested
the same macOS runner types while required checks were waiting for runners.
## Evidence
- [PR #264](https://github.com/Kuddev/pebrel/pull/264) changed Windows installer
scripts and triggered [Preview run 35825643107](https://github.com/Kuddev/pebrel/actions/runs/35825643107).
- The Preview macOS jobs ran workspace tests, dialog tests and an optimized
product build before creating DMGs. The native workflow independently ran
workspace tests and checked the production feature graph.
- The required `Release workspace` jobs execute `cargo check --release`; they
do not generate distribution packages and remain required compile checks.
- Runner waiting is observable. The exact hosted-runner quota or scheduling
policy responsible for a particular delay is not established by these logs.
## Decision
Remove the `pull_request` trigger from `preview-packages.yml`. Keep its filtered
`main` push trigger and explicit `workflow_dispatch`, including all package and
runtime conformance validation. Public Preview publication still requires an
explicit dispatch with publication enabled. Stable release triggers are retained.
The native PR and merge-group workflows retain their test coverage. This change
does not alter required-check names or server-side protection rules.
## Rejected alternatives
- Cancel required macOS checks to merge sooner: loses required validation.
- Treat the release-profile compile checks as package creation: these commands
validate a different build profile without creating installers or archives.
- Remove package conformance tests: package behavior still needs verification
when a package is actually requested.
## Consequences
Pull requests no longer automatically provide Preview installation artifacts.
Maintainers can explicitly dispatch package validation for a candidate branch.
Already queued workflows must be handled separately; changing a trigger cannot
retroactively cancel them. This change does not remove duplicate native runs
when a draft becomes ready, or guarantee a fixed runner wait time.
## Validation
The native-suite workflow contract verifies that Preview has only filtered main
push and manual triggers, that stable release remains tag/manual, and that native
PR and merge-group checks remain unfiltered. Restoring the previous PR package
trigger must fail the new regression.
## Supersedes
The previous automatic Preview build policy for pull-request package-input changes.
## Revisit when
An explicit request for per-PR artifacts can be implemented with bounded platform
selection and a separate runner budget without delaying mandatory validation.
@@ -0,0 +1,60 @@
# Same-version repair is not identified by a changed main binary
## Status
Implemented; pending review.
## Context
[Issue #258](https://github.com/Kuddev/pebrel/issues/258) supplies an Inno log
reporting successful installation and the expected Pebrel version, while the
update helper reports that the same-version binary did not change.
## Evidence
The reported preceding update replaced the main executable but failed with a
locked Hook helper. Reinstalling the same package repairs other files while
leaving the main executable byte-identical. Its hash cannot distinguish that
successful repair from an installation which did no useful work.
## Decision
Retain package SHA-256/size checks, commit authority, participant identity and
exit waiting, installer exit-code checks, and the installed version probe.
Remove the additional requirement that a same-version main binary change hash.
Record the installed hash as evidence, not as a repair-success predicate.
Before starting setup, retry exclusive write access to installed Hook helper files
for five seconds. Short-lived forwarding can finish; persistent locks fail before
any installation file is replaced. The helper never kills unrelated processes.
## Rejected alternatives
- Suppressing every installer error would hide partial/failed installations.
- Forcing a main-binary byte difference does not validate the repaired files.
- Disabling same-version retries prevents recovery after a partial upgrade.
## Consequences
Installer success and the expected application version determine success for a
verified package. The updater does not independently audit every installed file;
installer errors continue to fail the transaction. No installed version tag or
asset naming rule changes.
## Validation
The native handoff suite passes twelve scenarios, including a byte-identical main
binary with a repaired helper. The former same-version/no-change assertion was
incorrect; its negative intent is retained as `upgrade-noop`, which returns
installer success but leaves the wrong application version and must still fail.
Cancellation, checksum/identity failures and unprepared processes remain covered.
A released helper-file lock permits installation; a held lock must prevent setup
from starting and recover the unchanged original application.
## Supersedes
The same-version changed-hash assertion in the handoff helper and its old fixture.
## Revisit when
A versioned installed-file manifest provides a stronger package-completeness
check without confusing unchanged files with failed installation.
@@ -0,0 +1,59 @@
# Group WSL launch entries under one Explorer submenu
## Status
Implemented; pending review.
## Context
One top-level context item per WSL distribution crowds Explorer menus. The user
requested one ordinary Pebrel entry and one expandable entry for distribution
choices, for both selected directories and directory backgrounds.
## Evidence
The previous installer registered `PebrelWsl<index>` directly under each Explorer
shell root. Those entries remain registered after an upgrade unless explicitly
migrated. WSL distribution enumeration already excludes Docker plumbing entries.
## Decision
Keep the ordinary entry. Register a static `PebrelWslMenu` cascade with an empty
`SubCommands` value and its distribution verbs under `shell`. Record its executable
owner and validate child commands before replacing or removing the subtree.
Migrate only flat entries whose command invokes the exact owned executable.
Preserve `%1` for selected directories, `%V` for backgrounds, and the existing
`--shell` before `--working-directory` order. No distributions means no empty
submenu. Conflicting/edited subtrees and other installations remain untouched.
## Rejected alternatives
- Removing distribution choices reduces existing launch functionality.
- Deleting every key with the product prefix can remove another installation or
user customization.
- Installing a new Explorer extension adds deployment and lifetime requirements
unnecessary for this static cascade.
## Consequences
Registration still reflects distributions present at installation time. Existing
menus migrate when the new installer runs; changing this source does not modify
the user's live registry. Conflicts fail visibly rather than overwrite unknown keys.
## Validation
The real Inno fixture uses an isolated HKCU test subtree and passes 117 migration
checks in total. New coverage includes both roots, zero/one/three distributions,
Unicode/space-containing names, repeat registration, stale-entry removal, edited
subtrees and foreign ownership. The complete installer compiles without executing
an installation. Explorer presentation/click acceptance remains separate.
## Supersedes
The installer's flat WSL verb layout; the ordinary Pebrel verb is retained.
## Revisit when
Distribution registration must update outside installation, or Explorer requires
a different supported menu registration mechanism.
@@ -0,0 +1,77 @@
# Plan the native matrix before requesting runners
## Status
Implemented for review. Preserves the existing ten required check names and
ready-PR platform coverage; server-side rules are not changed.
## Context
The initial draft tier skipped individual steps on Intel Mac and Windows ARM64
jobs. GitHub still assigned runners before skipping those steps. In PR #257's
draft run 35820869856, the Intel test job waited 309 seconds before spending
eight seconds on a job whose test steps were skipped.
## Evidence
`runs-on` belongs to a job, while the old draft conditions belonged to its steps.
Avoiding the platform command does not avoid allocating that job's runner.
The required `lint` check already runs on Ubuntu and can validate both formatting
and the platform plan before expensive jobs are created.
## Decision
`scripts/ci_plan.py` is the authority for platform selection. It reads the event
JSON and produces native-test and release-compile matrices. Draft PRs select the
three existing core platforms and the Apple Silicon release check. Ready PRs,
main pushes, merge groups and release callers retain all five native platforms
and both macOS release checks.
The existing required `lint` job owns planning, after rustfmt. Native and release
jobs depend on that job and consume its validated outputs. Invalid input fails
the required check rather than silently returning an empty matrix. Checkouts
explicitly discard Git credentials; the workflow retains read-only permissions.
Draft-only deferred checks are absent, not reported as successful executions.
A ready PR must still supply every required platform result. This change does
not copy previous runs' check conclusions. Cargo caches accelerate rebuilding;
they are not evidence that the current validation succeeded.
## Rejected alternatives
- Keep step-only skipping: retains the unnecessary runner allocation.
- Move planning into an unrequired job: its failure could be less visible than
a failure in an existing required check, especially on repeated runs.
- Remove release-profile checks with Preview packaging: `cargo check --release`
verifies compilation and does not create installation packages.
- Mirror an earlier green check onto a new run: requires independently verified
source, base, workflow, configuration and provenance rules; cache hits alone
cannot establish those facts.
## Consequences
Heavy jobs begin after the Ubuntu lint/planning job. Formatting errors and bad
event metadata stop before requesting platform runners. Draft PR pages may show
deferred required contexts as missing; drafts are not mergeable, and the ready
event runs the full matrix. Ready transitions still rerun core validation.
This reduces avoidable runner requests without promising a fixed queue time.
The existing Windows diagnostic dispatch continues to use its separate workflow.
## Validation
Planner tests cover draft and ready PRs, all full-validation caller events,
malformed input and real command-line output. Workflow contract tests require the
existing lint check to own planning, both matrices to depend on it, draft step
guards to be absent, and release compilation to remain present. Native PR and
merge-group coverage and explicit failure propagation retain their regressions.
Actual hosted-runner execution remains subject to the PR's required CI results.
## Supersedes
Step-level draft skipping in PR #257's initial native matrix.
## Revisit when
Queue and execution measurements justify a different platform schedule, or a
separately reviewed artifact-provenance design supports build-once test shards.
+24
View File
@@ -170,6 +170,9 @@ const WSLENV_ENTRIES: &[&str] = &[
crate::ai_hook::LEGACY_PANE_ENV,
TERM_PROGRAM_ENV,
TERM_PROGRAM_VERSION_ENV,
// WSL otherwise falls back to a 256-color TERM despite the host supporting
// truecolor. Keep the capability declaration; do not spoof another terminal.
"COLORTERM",
"PEBREL_CLI/p",
"PEBREL_BIN_DIR/p",
PROCESS_ENV,
@@ -311,6 +314,27 @@ mod tests {
assert!(names.contains(&BIN_DIR_ENV), "{BIN_DIR_ENV} missing: {names:?}");
assert!(names.contains(&PROCESS_ENV), "{PROCESS_ENV} missing: {names:?}");
assert!(names.contains(&crate::runtime_api::ENDPOINT_ENV));
assert!(names.contains(&"COLORTERM"));
}
#[cfg(windows)]
#[test]
fn wsl_receives_truecolor_without_replacing_an_explicit_forwarding_rule() {
let mut env = HashMap::from([
("COLORTERM".to_owned(), "truecolor".to_owned()),
("WSLENV".to_owned(), "KEEP/p".to_owned()),
]);
apply(&mut env, 8);
assert_eq!(env["COLORTERM"], "truecolor");
assert!(env["WSLENV"].split(':').any(|entry| entry == "COLORTERM"));
assert!(env["WSLENV"].split(':').any(|entry| entry == "KEEP/p"));
env.insert("WSLENV".into(), "KEEP/p:COLORTERM/u".into());
env.insert("COLORTERM".into(), "24bit".into());
apply(&mut env, 8);
assert_eq!(env["COLORTERM"], "24bit");
let entries: Vec<_> =
env["WSLENV"].split(':').filter(|entry| variable_name(entry) == "COLORTERM").collect();
assert_eq!(entries, ["COLORTERM/u"]);
}
#[cfg(windows)]
+76 -2
View File
@@ -111,8 +111,10 @@ pub(super) fn ensure_codex_hooks() -> bool {
}
fn groups(helper: &str, mode: CodexHookMode) -> Value {
// Codex 的 Windows runner 使用 COMSPEC /C;可执行文件路径自身仍需引号。
let command = format!("\"{}\" codex {}", helper.replace('"', "\\\""), mode.argument());
// Codex runs Windows hooks through PowerShell -Command. A quoted path is
// a string expression until invoked with &, and single quotes keep $, `
// and other path characters literal. PowerShell escapes ' by doubling it.
let command = format!("& '{}' codex {}", helper.replace('\'', "''"), mode.argument());
installation::codex_groups(&command, Some(&command), mode)
}
@@ -260,6 +262,78 @@ fn remove(directory: &Path) -> io::Result<bool> {
mod tests {
use super::*;
#[test]
fn upgrades_cmd_style_owned_hooks_without_changing_user_entries() {
let root = tempfile::tempdir().unwrap();
let helper = "C:/Program Files/Pebrel/runtime/pebrel-hook.exe";
let old_command = format!("\"{helper}\" codex --hooks=full");
let previous =
installation::codex_groups(&old_command, Some(&old_command), CodexHookMode::Full);
let foreign = json!({"hooks":[{"type":"command","command":"user-hook"}]});
let mut hooks = json!({"hooks": previous});
hooks["hooks"]["Stop"].as_array_mut().unwrap().push(foreign.clone());
std::fs::write(root.path().join("hooks.json"), hooks.to_string()).unwrap();
std::fs::write(
root.path().join(MARKER),
json!({"groups": previous, "enabled_feature": true}).to_string(),
)
.unwrap();
let config = "notify = ['user-notifier']\n[features]\nhooks = true\n";
std::fs::write(root.path().join("config.toml"), config).unwrap();
assert!(!current_for_mode(root.path(), helper, Some(CodexHookMode::Full)).unwrap());
assert!(install(root.path(), helper, CodexHookMode::Full).unwrap());
assert!(current_for_mode(root.path(), helper, Some(CodexHookMode::Full)).unwrap());
assert!(!install(root.path(), helper, CodexHookMode::Full).unwrap());
let hooks: Value =
serde_json::from_str(&read(&root.path().join("hooks.json")).unwrap().unwrap()).unwrap();
let mut expected = json!({"hooks": groups(helper, CodexHookMode::Full)});
expected["hooks"]["Stop"].as_array_mut().unwrap().insert(0, foreign);
assert_eq!(hooks, expected);
assert_eq!(read(&root.path().join("config.toml")).unwrap().unwrap(), config);
}
#[test]
fn powershell_executes_generated_hooks_with_literal_paths_and_stdin() {
use std::io::Write as _;
use std::os::windows::process::CommandExt as _;
let root = tempfile::tempdir().unwrap();
let helper = root.path().join("Pebrel's $data `hook.ps1");
std::fs::write(
&helper,
"Write-Output ($args -join '|')\r\nWrite-Output ([Console]::In.ReadLine())\r\n",
)
.unwrap();
let powershell = Path::new(&std::env::var_os("SystemRoot").unwrap())
.join("System32/WindowsPowerShell/v1.0/powershell.exe");
for mode in [CodexHookMode::Turns, CodexHookMode::Full] {
let groups = groups(&helper.to_string_lossy().replace('\\', "/"), mode);
let command = groups["Stop"][0]["hooks"][0]["command"].as_str().unwrap();
let mut child = Command::new(&powershell)
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
command,
])
.creation_flags(0x0800_0000)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(b"{\"session_id\":\"fixture\"}\n").unwrap();
let output = child.wait_with_output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains(&format!("codex|{}", mode.argument())), "{stdout}");
assert!(stdout.contains(r#"{"session_id":"fixture"}"#), "{stdout}");
}
}
#[test]
fn install_upgrade_remove_preserves_notify_and_user_hooks() {
let root = tempfile::tempdir().unwrap();
+3
View File
@@ -37,6 +37,9 @@ use crate::config::window::WindowConfig;
/// Regex used for default terminal hints (Markdown links, URLs, and local paths).
#[rustfmt::skip]
pub const DEFAULT_HINT_REGEX: &str = "(?:\
(?m:^[a-z0-9_.-]+@[a-z0-9_.-]+:/[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s`]*[$#])|\
\"(?:[a-z]:[/\\\\]|\\\\\\\\|~[/\\\\])[^\u{0000}-\u{001F}\u{007F}-\u{009F}\"\\r\\n]+\"|\
'(?:[a-z]:[/\\\\]|\\\\\\\\|~[/\\\\])[^\u{0000}-\u{001F}\u{007F}-\u{009F}'\\r\\n]+'|\
\\[[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n\\]]+\\]\\((?:[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]|\\([^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]*\\))+\\)|\
(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`\\\\]+|\
(?:(?-u:\\b)[a-z]:[/\\\\]|\\\\\\\\)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`*?:,。;:!?、]+|\
+130 -2
View File
@@ -585,9 +585,45 @@ impl<'a, T> HintPostProcessor<'a, T> {
let mut iter = self.term.grid().iter_from(*regex_match.start());
let mut c = iter.cell().c;
let mut start = *regex_match.start();
let end = *regex_match.end();
if start == self.term.line_search_left(start) && c.is_ascii_alphanumeric() {
let mut prefix = self.term.grid().iter_from(start);
let mut at = false;
loop {
let ch = prefix.cell().c;
if ch == ':' && at {
if let Some(next) = prefix.next()
&& next.c == '/'
{
start = next.point;
iter = self.term.grid().iter_from(start);
c = iter.cell().c;
}
break;
}
if !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '@'))
|| prefix.point() >= end
{
break;
}
at |= ch == '@';
if prefix.next().is_none() {
break;
}
}
}
// Quoted local paths have an explicit boundary and may contain spaces,
// punctuation and parentheses. Keep the target, not its shell quotes.
if matches!(c, '\'' | '"') && start < end && self.term.grid()[end].c == c {
let inner_start = start.add(self.term, Boundary::Grid, 1);
let inner_end = end.sub(self.term, Boundary::Grid, 1);
if inner_start <= inner_end {
return Some(inner_start..=inner_end);
}
}
// Truncate uneven number of brackets.
let end = *regex_match.end();
let mut open_parents = 0;
let mut open_brackets = 0;
loop {
@@ -624,7 +660,14 @@ impl<'a, T> HintPostProcessor<'a, T> {
}
// Truncate trailing characters which are likely to be delimiters.
let start = *regex_match.start();
// A default Bash/WSL prompt has no space between its cwd and $/#.
// Only trim that suffix in a user@host:~/... prompt; '$' is otherwise
// legal in paths and URLs, and OSC 8 targets bypass these heuristics.
if matches!(c, '$' | '#') && self.is_shell_prompt_path(start, iter.point()) {
if let Some(indexed) = iter.prev() {
c = indexed.cell.c;
}
}
while iter.point() != start {
if !matches!(
c,
@@ -660,6 +703,32 @@ impl<'a, T> HintPostProcessor<'a, T> {
if start > iter.point() { None } else { Some(start..=iter.point()) }
}
fn is_shell_prompt_path(&self, start: Point, end: Point) -> bool {
if !matches!(self.term.grid()[start].c, '~' | '/') {
return false;
}
let line_start = self.term.line_search_left(start);
if start == line_start {
return false;
}
let mut after = self.term.grid().iter_from(end);
if after.next().is_some_and(|cell| !cell.c.is_whitespace()) {
return false;
}
let before = start.sub(self.term, Boundary::Grid, 1);
let prefix = self.term.bounds_to_string(line_start, before);
let Some(prefix) = prefix.split_whitespace().next_back() else { return false };
let Some((user, host)) = prefix.strip_suffix(':').and_then(|s| s.rsplit_once('@')) else {
return false;
};
!user.is_empty()
&& !host.is_empty()
&& user
.chars()
.chain(host.chars())
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
}
/// Loop over submatches until a non-empty post-processed match is found.
fn next_processed_match(&mut self, mut regex_match: Match) {
self.next_match = loop {
@@ -705,6 +774,65 @@ mod tests {
use super::*;
#[test]
fn shell_prompt_symbols_are_not_part_of_clickable_home_paths() {
for (text, expected) in [
("alice@workstation:/mnt/d/project$ ", "/mnt/d/project"),
("root@host:/root# ", "/root"),
("user@host:/# ", "/"),
("alice@workstation:~/.codex$ ", "~/.codex"),
("root@host:~/work# ", "~/work"),
("alice@workstation:~/工作文档$ echo test", "~/工作文档"),
("~/cost$ ", "~/cost$"),
("~/repo# ", "~/repo#"),
("https://example.com/cost$ ", "https://example.com/cost$"),
("user@host:~/price$usd ", "~/price$usd"),
] {
let term = mock_term(text);
let config = UiConfig::default();
let matches = visible_clickable_matches(&term, &config);
assert_eq!(matches.len(), 1, "{text}: {matches:?}");
assert_eq!(term.bounds_to_string(*matches[0].start(), *matches[0].end()), expected);
let end = *matches[0].end();
let hit = highlighted_at(&term, &config, end, ModifiersState::CONTROL).unwrap();
assert_eq!(hit.text(&term).unwrap().as_ref(), expected);
assert!(
highlighted_at(
&term,
&config,
end.add(&term, Boundary::Grid, 1),
ModifiersState::CONTROL
)
.is_none()
);
}
}
#[test]
fn quoted_paths_keep_spaces_and_literal_punctuation_in_hover_and_click_targets() {
for (text, expected) in [
(
r#"& "C:\Program Files\Example Tools\probe.exe" pr view"#,
r"C:\Program Files\Example Tools\probe.exe",
),
("open 'D:/My Documents/开始 菜单 (1).md' now", "D:/My Documents/开始 菜单 (1).md"),
("cat '~/cost$'", "~/cost$"),
(r#"open "\\server\my share\report.txt" now"#, r"\\server\my share\report.txt"),
] {
let term = mock_term(text);
let config = UiConfig::default();
let matches = visible_clickable_matches(&term, &config);
assert_eq!(matches.len(), 1, "{text}: {matches:?}");
let start = *matches[0].start();
let end = *matches[0].end();
assert_eq!(term.bounds_to_string(start, end), expected);
for point in [start, end] {
let hit = highlighted_at(&term, &config, point, ModifiersState::CONTROL).unwrap();
assert_eq!(hit.text(&term).unwrap().as_ref(), expected);
}
}
}
#[test]
fn hint_label_generation() {
let mut generator = HintLabels::new("0123", 0.5);
+70 -48
View File
@@ -9,8 +9,6 @@
//! 共同塑形,再由 `ligatures` 把字形簇映射回固定列号。其余字符逐格
//! 绘制,组合字符跟随基字;编辑、光标和选区不能改变无关格的原点。
use std::collections::HashSet;
use gpui::{
App, Bounds, ContentMask, Corners, CursorStyle, DispatchPhase, Element, ElementId,
GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, LayoutId, MouseButton,
@@ -28,6 +26,10 @@ use nebula_terminal::vte::ansi::{Color, CursorShape, NamedColor};
use super::colors::Palette;
use super::view::TerminalView;
#[cfg(test)]
#[path = "element/color_tests.rs"]
mod color_tests;
pub struct TerminalElement {
view: gpui::Entity<TerminalView>,
}
@@ -84,7 +86,8 @@ impl TerminalElement {
rows: usize,
cols: usize,
cx: &App,
) -> Option<(RenderSnapshot, Option<String>, usize, HashSet<(u16, u16)>, usize, i64)> {
) -> Option<(RenderSnapshot, Option<String>, usize, super::osc_links::LinkCells, usize, i64)>
{
let view = self.view.read(cx);
let session = view.session.as_ref()?;
let hint_config = view.hint_config.clone();
@@ -139,12 +142,23 @@ impl TerminalElement {
fn resolve_app_colors(
&self,
snap: &mut RenderSnapshot,
links: &mut super::osc_links::LinkCells,
theme: &Palette,
overrides: &Colors,
cx: &mut App,
) {
self.view.update(cx, |view, _| {
resolve_app_colors_into(snap, theme, overrides, &mut view.color_resolver);
for cell in links.values_mut() {
cell.fg = resolve_text_foreground(
cell.fg,
cell.bg,
cell.bold,
theme,
overrides,
&mut view.color_resolver,
);
}
});
}
}
@@ -281,7 +295,7 @@ impl Element for TerminalElement {
let focused = focus_handle.is_focused(window);
// 旧壳只让光标本身参与闪烁;ghost、弹窗补齐和 IME 仍复用同一个坐标锚点。
let cursor_visible = self.view.read(cx).cursor_visible();
let Some((mut snap, prompt_line, history, dashed, scrollback_floor, viewport_top_abs)) =
let Some((mut snap, prompt_line, history, mut dashed, scrollback_floor, viewport_top_abs)) =
self.snapshot(layout.rows, layout.cols, cx)
else {
return;
@@ -295,7 +309,7 @@ impl Element for TerminalElement {
view.drive_pending_remote_dir(cx);
});
let overrides = snap.color_overrides;
self.resolve_app_colors(&mut snap, &theme, &overrides, cx);
self.resolve_app_colors(&mut snap, &mut dashed, &theme, &overrides, cx);
let (theme_anchor, theme_is_light) = themed_anchor(&theme, cx);
let host_cursor_follows_theme = is_default_host_cursor(&theme);
let app_cursor = snap.cursor.as_ref().filter(|cursor| {
@@ -606,7 +620,7 @@ impl Element for TerminalElement {
} else {
theme.resolve(cell.fg, &overrides, cell.bold).into()
};
let dashed_link = dashed.contains(&(seg.row, cell.col));
let dashed_link = dashed.contains_key(&(seg.row, cell.col));
let underline = (cell.underline && !dashed_link).then(|| UnderlineStyle {
thickness: px(1.0),
color: Some(fg),
@@ -639,7 +653,7 @@ impl Element for TerminalElement {
== cursor_inverts(seg.row, cell.col)
&& selected_foreground(seg.row, next.col)
== selected_foreground(seg.row, cell.col)
&& !dashed.contains(&(seg.row, next.col))
&& !dashed.contains_key(&(seg.row, next.col))
})
} else {
1
@@ -674,17 +688,34 @@ impl Element for TerminalElement {
layout.line_height,
);
}
if dashed_link {
paint_dashed_underline(
}
}
// Link decoration follows grid columns, including wide-character spacer
// cells and spaces omitted by text shaping. Every cell shares one dash
// phase, so font fallback and ASCII/CJK boundaries cannot restart it.
for (&(row, col), cell) in &dashed {
if math_frame.covers(row as usize, col as usize) {
continue;
}
let Some(visual_col) = math_frame.project_cell(row as usize, col as usize, layout.cols)
else {
continue;
};
let color = if cursor_inverts(row, col) {
theme.cursor_text.unwrap_or(theme.background)
} else if let Some(foreground) = selected_foreground(row, col) {
foreground
} else {
theme.resolve(cell.fg, &overrides, cell.bold)
};
super::link_underline::paint(
window,
origin,
layout.cell_width,
layout.line_height,
fg,
cell_rect(row as usize, visual_col, 1),
bounds.origin.x,
color.into(),
);
}
}
}
// 公式位图画在格子文本之后、装饰(ghost/光标/滚动条)之前,
// 与旧壳 draw_rects → draw_overlays 的次序一致。
@@ -945,27 +976,6 @@ impl Element for TerminalElement {
}
}
fn paint_dashed_underline(
window: &mut Window,
origin: gpui::Point<Pixels>,
cell_width: Pixels,
line_height: Pixels,
color: Hsla,
) {
let y = origin.y + line_height - px(1.0);
let end: f32 = origin.x.as_f32() + cell_width.as_f32();
let mut x: f32 = origin.x.as_f32();
let dash: f32 = 3.0;
let gap: f32 = 2.0;
while x < end {
let width = f32::min(dash, end - x);
if width > 0.0 {
window.paint_quad(fill(Bounds::new(point(px(x), y), size(px(width), px(1.0))), color));
}
x += dash + gap;
}
}
/// Align geometry-only terminal primitives to the device pixel grid. Cell
/// widths/heights are already measured in device pixels and converted back to
/// logical units, but a pane origin can still be fractional after a split or
@@ -1642,9 +1652,6 @@ fn resolve_app_colors_into(
use crate::display::content::is_terminal_graphic;
use crate::display::terminal_color::is_fixed_color;
let theme_fg = rgb_from_rgba(theme.foreground);
let theme_bg = rgb_from_rgba(theme.background);
for run in &mut snap.bg_runs {
let base = rgb_from_rgba(theme.resolve(run.color, overrides, false));
let resolved = resolver.resolve_background(base, is_fixed_color(run.color, overrides));
@@ -1662,18 +1669,33 @@ fn resolve_app_colors_into(
// 对比度是一对颜色的属性:这个前景可不可读,取决于它**这一格**底下是
// 什么,而不是主题底色。默认底色的格子没有 bg run,所以 `SnapCell::bg`
// 单独带着这个值。
let bg_base = rgb_from_rgba(theme.resolve(cell.bg, overrides, false));
let bg = resolver.resolve_background(bg_base, is_fixed_color(cell.bg, overrides));
// bold 提亮(0-7 → 8-15)必须发生在矫正**之前**,否则写回的 `Spec` 会把
// 提亮吃掉。
let base = rgb_from_rgba(theme.resolve(cell.fg, overrides, cell.bold));
let resolved = resolver.resolve_foreground(base, bg, true, theme_fg, theme_bg);
if resolved != base {
cell.fg = Color::Spec(resolved.0);
}
cell.fg = resolve_text_foreground(cell.fg, cell.bg, cell.bold, theme, overrides, resolver);
}
}
fn resolve_text_foreground(
fg: Color,
bg: Color,
bold: bool,
theme: &Palette,
overrides: &Colors,
resolver: &mut crate::display::terminal_color::TerminalColorResolver,
) -> Color {
use crate::display::terminal_color::is_fixed_color;
let bg_base = rgb_from_rgba(theme.resolve(bg, overrides, false));
let bg = resolver.resolve_background(bg_base, is_fixed_color(bg, overrides));
// Resolve bold before contrast adjustment; Spec must retain that brightening.
let base = rgb_from_rgba(theme.resolve(fg, overrides, bold));
let resolved = resolver.resolve_foreground(
base,
bg,
true,
rgb_from_rgba(theme.foreground),
rgb_from_rgba(theme.background),
);
if resolved != base { Color::Spec(resolved.0) } else { fg }
}
pub(super) fn rgba_rgb(color: crate::display::color::Rgb, alpha: f32) -> Rgba {
Rgba {
r: f32::from(color.r) / 255.0,
@@ -0,0 +1,99 @@
//! Replay the terminal features required by Codex message surfaces and stars.
//! Fixture colors follow codex rust-v0.154.0 tui/src/style.rs and
//! bottom_pane/chat_composer/sparkle.rs; this does not emulate a live Codex session.
use nebula_terminal::event::VoidListener;
use nebula_terminal::render::{RenderSnapshot, SnapshotConfig};
use nebula_terminal::term::color::Colors;
use nebula_terminal::term::test::TermSize;
use nebula_terminal::term::{Config, Term};
use nebula_terminal::vte::ansi::{self, Color, NamedColor, Rgb};
use super::{Palette, resolve_app_colors_into, rgb_from_rgba};
use crate::display::terminal_color::TerminalColorResolver;
use crate::gpui_shell::terminal::colors::from_ansi_rgb;
use crate::gpui_shell::theme::ResolvedTheme;
fn palette(name: nebula_settings::ThemeName) -> Palette {
let resolved = ResolvedTheme::builtin(name, None);
let color = |[r, g, b]: [u8; 3]| from_ansi_rgb(Rgb { r, g, b });
Palette {
background: color(resolved.terminal_background()),
foreground: color(resolved.terminal_foreground()),
..Palette::default()
}
}
fn capture(palette: &Palette) -> (RenderSnapshot, Rgb) {
let bg = palette.query_reply(NamedColor::Background as usize, &Colors::default());
let fg = palette.query_reply(NamedColor::Foreground as usize, &Colors::default());
let (top, alpha) = if palette.is_dark() { (255.0, 0.12) } else { (0.0, 0.04) };
let blend = |channel: u8| (channel as f32 * (1.0 - alpha) + top * alpha).round() as u8;
let user = Rgb { r: blend(bg.r), g: blend(bg.g), b: blend(bg.b) };
let bytes = format!(
"\x1b[48;2;{};{};{}m\x1b[38;2;{};{};{}m用户 message\x1b[K\r\n⠁⠂⠄⠈⠐⠠⡀⢀\x1b[K\r\n\x1b[0mAI reply",
user.r, user.g, user.b, fg.r, fg.g, fg.b,
);
let mut term = Term::new(Config::default(), &TermSize::new(40, 4), VoidListener);
let mut parser: ansi::Processor = ansi::Processor::new();
parser.advance(&mut term, bytes.as_bytes());
(RenderSnapshot::capture(&term, &SnapshotConfig { rows: 4, cols: 40 }), user)
}
#[test]
fn every_builtin_theme_keeps_message_surfaces_and_braille_stars() {
for name in nebula_settings::ThemeName::BUILTIN {
let palette = palette(name);
let (mut snapshot, user) = capture(&palette);
assert_ne!(from_ansi_rgb(user), palette.background, "{name:?}");
let star_colors: Vec<_> = snapshot
.segments
.iter()
.filter(|seg| seg.row == 1)
.flat_map(|seg| seg.cells.iter().map(|cell| cell.fg))
.collect();
let mut resolver = TerminalColorResolver::default();
resolve_app_colors_into(&mut snapshot, &palette, &Colors::default(), &mut resolver);
for row in [0, 1] {
let run = snapshot.bg_runs.iter().find(|run| run.row == row).unwrap();
assert_eq!((run.start, run.end, run.color), (0, 40, Color::Spec(user)), "{name:?}");
}
assert!(
snapshot.bg_runs.iter().all(|run| run.row != 2),
"{name:?}: AI reply acquired a message background"
);
let stars: String = snapshot
.segments
.iter()
.filter(|seg| seg.row == 1)
.flat_map(|seg| seg.cells.iter().map(|cell| cell.text.as_str()))
.collect();
assert_eq!(stars, "⠁⠂⠄⠈⠐⠠⡀⢀");
let after: Vec<_> = snapshot
.segments
.iter()
.filter(|seg| seg.row == 1)
.flat_map(|seg| seg.cells.iter().map(|cell| cell.fg))
.collect();
assert_eq!(star_colors, after, "{name:?}: star colors were changed");
}
}
#[test]
fn message_background_remains_distinct_after_every_builtin_theme_switch() {
for from in nebula_settings::ThemeName::BUILTIN {
for to in nebula_settings::ThemeName::BUILTIN {
let original = palette(from);
let next = palette(to);
let (mut snapshot, _) = capture(&original);
let mut resolver = TerminalColorResolver::default();
resolver
.theme_changed(rgb_from_rgba(original.background), rgb_from_rgba(next.background));
resolve_app_colors_into(&mut snapshot, &next, &Colors::default(), &mut resolver);
let user = snapshot.bg_runs.iter().find(|run| run.row == 0).unwrap();
let background = next.resolve(user.color, &Colors::default(), false);
assert_ne!(background, next.background, "{from:?} -> {to:?}");
assert!(snapshot.bg_runs.iter().all(|run| run.row != 2));
}
}
}
@@ -0,0 +1,130 @@
//! Link dashes in physical grid coordinates, independent of glyph width/shaping.
use gpui::{Bounds, Hsla, Pixels, Window, fill, point, px, size};
pub(super) fn paint(window: &mut Window, cell: Bounds<Pixels>, grid_left: Pixels, color: Hsla) {
let scale = window.scale_factor().max(0.5);
let y = (cell.bottom().as_f32() * scale).round();
let thickness = scale.round().max(1.0);
for (left, right) in
dash_segments(cell.left().as_f32(), cell.right().as_f32(), grid_left.as_f32(), scale)
{
window.paint_quad(fill(
Bounds::new(
point(px(left / scale), px((y - thickness) / scale)),
size(px((right - left) / scale), px(thickness / scale)),
),
color,
));
}
}
fn dash_segments(
left: f32,
right: f32,
anchor: f32,
scale: f32,
) -> impl Iterator<Item = (f32, f32)> {
let left = (left * scale).round();
let right = (right * scale).round();
let anchor = (anchor * scale).round();
let dash = (3.0 * scale).round().max(1.0);
let period = dash + (2.0 * scale).round().max(1.0);
let mut x = anchor + ((left - anchor) / period).floor() * period;
std::iter::from_fn(move || {
while x < right {
let start = x.max(left);
let end = (x + dash).min(right);
x += period;
if start < end {
return Some((start, end));
}
}
None
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::*;
use crate::config::UiConfig;
use crate::gpui_shell::terminal::osc_links::dashed_cells;
use nebula_terminal::event::VoidListener;
use nebula_terminal::term::test::TermSize;
use nebula_terminal::term::{Config, Term};
use nebula_terminal::vte::ansi;
#[test]
#[ignore = "informational hot-path measurement; no machine-specific timing gate"]
fn measure_visible_link_decoration_work() {
for (name, linked, text) in
[("plain", false, "x"), ("ascii", true, "x"), ("cjk", true, "中")]
{
let mut term = Term::new(Config::default(), &TermSize::new(120, 40), VoidListener);
let mut parser: ansi::Processor = ansi::Processor::new();
let text = text.repeat(if name == "cjk" { 50 } else { 100 });
let row = if linked {
format!("\x1b]8;;file:///fixture\x1b\\{text}\x1b]8;;\x1b\\\r\n")
} else {
format!("{text}\r\n")
};
parser.advance(&mut term, row.repeat(35).as_bytes());
let config = UiConfig::default();
let started = std::time::Instant::now();
let mut last_cells = 0;
for _ in 0..200 {
let cells = dashed_cells(&term, &config, 40, 120);
last_cells = cells.len();
let segments: usize = cells
.keys()
.map(|&(_, col)| {
dash_segments(
2.3 + f32::from(col) * 8.4,
2.3 + f32::from(col + 1) * 8.4,
2.3,
1.25,
)
.count()
})
.sum();
std::hint::black_box((cells, segments));
}
crate::gpui_shell::try_write_stderr(format_args!(
"{name}: 120x40 grid, {last_cells} linked columns, {:.1} us/pass (capture + dash geometry, no GPU)",
started.elapsed().as_secs_f64() * 1_000_000.0 / 200.0
));
}
}
#[test]
fn real_osc8_grid_covers_cjk_spacers_and_spaces_with_one_dash_phase() {
let mut term = Term::new(Config::default(), &TermSize::new(40, 2), VoidListener);
let mut parser: ansi::Processor = ansi::Processor::new();
parser.advance(
&mut term,
"\x1b]8;;file:///tmp/example\x1b\\A开始 菜单Z\x1b]8;;\x1b\\".as_bytes(),
);
let cells = dashed_cells(&term, &UiConfig::default(), 2, 40);
assert_eq!(cells.len(), 11);
assert!((0..11).all(|col| cells.contains_key(&(0, col))));
for scale in [1.0, 1.25, 1.5, 2.0] {
for width in [7.0, 8.4, 9.5, 12.0] {
let anchor = 2.3;
let pixels = |left, right| {
dash_segments(left, right, anchor, scale)
.flat_map(|(left, right)| left as i32..right as i32)
};
let actual: BTreeSet<_> = cells
.keys()
.flat_map(|&(_, col)| {
pixels(anchor + f32::from(col) * width, anchor + f32::from(col + 1) * width)
})
.collect();
let expected: BTreeSet<_> = pixels(anchor, anchor + 11.0 * width).collect();
assert_eq!(actual, expected, "scale={scale}, width={width}");
}
}
}
}
@@ -12,6 +12,7 @@ mod event_mailbox;
mod inline_image;
pub mod keymap;
mod ligatures;
mod link_underline;
pub mod math_overlay;
mod molecule_overlay;
pub mod mouse_protocol;
@@ -3,13 +3,15 @@
//! 匹配与动作全部复用旧壳 `display::hint` + `file_uri` + `daemon`,这里只做
//! 视口坐标、GPUI 修饰键和打开入口。
use std::collections::HashSet;
use std::collections::HashMap;
use std::sync::Arc;
use gpui::{App, ClipboardItem, Window};
use nebula_terminal::event::EventListener;
use nebula_terminal::index::Point;
use nebula_terminal::term::cell::Flags;
use nebula_terminal::term::{Term, point_to_viewport_from};
use nebula_terminal::vte::ansi::Color;
use unicode_width::UnicodeWidthChar;
use winit::keyboard::ModifiersState;
@@ -48,25 +50,42 @@ pub(super) fn winit_mouse_mods(mods: &gpui::Modifiers) -> ModifiersState {
}
/// 可见可点范围(OSC 8 + 正则 URL)映射到当前视口格子,供虚线下划线使用。
pub(super) struct LinkCell {
pub fg: Color,
pub bg: Color,
pub bold: bool,
}
pub(super) type LinkCells = HashMap<(u16, u16), LinkCell>;
pub(super) fn dashed_cells<T: EventListener>(
term: &Term<T>,
config: &UiConfig,
rows: usize,
cols: usize,
) -> HashSet<(u16, u16)> {
) -> LinkCells {
let matches = hint::visible_clickable_matches(term, config);
if matches.is_empty() {
return HashSet::new();
return HashMap::new();
}
let origin = term.viewport_origin_for(rows);
let mut cells = HashSet::new();
let mut cells = HashMap::new();
for indexed in term.grid().display_iter() {
if !matches.iter().any(|bounds| bounds.contains(&indexed.point)) {
if indexed.flags.intersects(Flags::HIDDEN | Flags::LEADING_WIDE_CHAR_SPACER)
|| !matches.iter().any(|bounds| bounds.contains(&indexed.point))
{
continue;
}
let Some(vp) = point_to_viewport_from(origin, indexed.point) else { continue };
if vp.line < rows && vp.column.0 < cols {
cells.insert((vp.line as u16, vp.column.0 as u16));
let (mut fg, mut bg) = (indexed.fg, indexed.bg);
if indexed.flags.contains(Flags::INVERSE) {
std::mem::swap(&mut fg, &mut bg);
}
cells.insert(
(vp.line as u16, vp.column.0 as u16),
LinkCell { fg, bg, bold: indexed.flags.contains(Flags::BOLD) },
);
}
}
cells
@@ -110,12 +129,61 @@ pub(super) fn open_hint_match(
hint: &HintMatch,
text: &str,
cwd: Option<&std::path::Path>,
launch: &crate::session::LaunchSession,
window: &mut Window,
cx: &mut App,
) {
let target = (hint.hyperlink().is_none()
&& hint.action() == &HintAction::Command(default_hint_command()))
.then(|| wsl_absolute_target(text, launch))
.flatten();
let text = target.as_deref().unwrap_or(text);
dispatch_hint_action(hint.action(), hint.hyperlink().is_some(), text, cwd, window, cx);
}
fn wsl_absolute_target(text: &str, launch: &crate::session::LaunchSession) -> Option<String> {
if !text.starts_with('/') || text.starts_with("//") {
return None;
}
let (program, args) = match launch {
crate::session::LaunchSession::Shell { program, args, .. } => (program, args),
crate::session::LaunchSession::Profile { command, args, .. } => (command, args),
_ => return None,
};
let distro = crate::shell_detect::wsl_launch_distro(program, args)?;
Some(crate::shell_detect::wsl_unc_path(distro, text).to_string_lossy().into_owned())
}
#[cfg(test)]
mod path_tests {
use super::*;
#[test]
fn absolute_prompt_paths_use_the_owning_wsl_distribution() {
let launch = crate::session::LaunchSession::Shell {
name: "Debian".into(),
program: "wsl.exe".into(),
args: vec!["-d".into(), "Debian".into()],
};
assert_eq!(
wsl_absolute_target("/mnt/d/project", &launch).as_deref(),
Some(r"\\wsl.localhost\Debian\mnt\d\project")
);
assert!(wsl_absolute_target("//example.com/file", &launch).is_none());
assert!(wsl_absolute_target("https://example.com", &launch).is_none());
assert!(
wsl_absolute_target(
"/home/user",
&crate::session::LaunchSession::Ssh { host: "remote".into() }
)
.is_none()
);
assert!(
wsl_absolute_target("/home/user", &crate::session::LaunchSession::Default).is_none()
);
}
}
fn dispatch_hint_action(
action: &HintAction,
hyperlink: bool,
@@ -178,7 +178,14 @@ impl TerminalView {
};
let Some(text) = text else { return };
let cwd = self.local_cwd();
super::super::osc_links::open_hint_match(&hover.hint, &text, cwd.as_deref(), window, cx);
super::super::osc_links::open_hint_match(
&hover.hint,
&text,
cwd.as_deref(),
&self.session_launch,
window,
cx,
);
}
/// 应用是否接管了鼠标(vim/htop 等)。Shift 按住时强制旁路——这是
+96 -9
View File
@@ -9,7 +9,7 @@ pub(crate) fn prepare_local_pty(options: &mut nebula_terminal::tty::Options) {
}
let inherited_override =
!options.env_is_complete && std::env::var_os(GROK_LEGACY_CONSOLE).is_some();
apply_windows_terminal_defaults(options, inherited_override);
apply_local_console_defaults(options, inherited_override);
}
#[cfg(not(windows))]
let _ = options;
@@ -19,10 +19,20 @@ pub(crate) fn prepare_local_pty(options: &mut nebula_terminal::tty::Options) {
const GROK_LEGACY_CONSOLE: &str = "GROK_FORCE_LEGACY_CONSOLE";
#[cfg(windows)]
fn apply_windows_terminal_defaults(
fn apply_local_console_defaults(
options: &mut nebula_terminal::tty::Options,
inherited_override: bool,
) {
// The refreshed, complete environment is captured before the GPUI spawn
// calls tty::setup_env(). Updating the parent later cannot change that
// snapshot. Advertise our actual capabilities in the child environment,
// including Explorer launches with no parent terminal. Keep explicit values.
for (name, value) in [("TERM", "xterm-256color"), ("COLORTERM", "truecolor")] {
if !options.env.keys().any(|key| key.eq_ignore_ascii_case(name)) {
let inherited = if options.env_is_complete { None } else { std::env::var(name).ok() };
options.env.insert(name.to_owned(), inherited.unwrap_or_else(|| value.to_owned()));
}
}
// Grok 1.0.25 treats unknown Windows terminal names as legacy consoles and
// omits its Braille logo. Keep our real identity and use its capability override.
// Remove this default when Grok recognizes Pebrel's terminal capabilities.
@@ -94,13 +104,13 @@ mod tests {
env_is_complete: true,
..Default::default()
};
super::apply_windows_terminal_defaults(&mut options, false);
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options.env[super::GROK_LEGACY_CONSOLE], "0");
assert_eq!(options.env["TERM_PROGRAM"], "pebrel");
assert_eq!(options.env["WSLENV"], "KEEP/p");
assert!(!options.env.contains_key("WT_SESSION"));
let once = options.clone();
super::apply_windows_terminal_defaults(&mut options, false);
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options, once);
}
@@ -110,12 +120,18 @@ mod tests {
for name in [super::GROK_LEGACY_CONSOLE, "grok_force_legacy_console"] {
for value in ["1", "true", "0", "false", ""] {
let mut options = nebula_terminal::tty::Options {
env: [(name.to_owned(), value.to_owned())].into_iter().collect(),
env: [
(name.to_owned(), value.to_owned()),
("TERM".to_owned(), "xterm-256color".to_owned()),
("COLORTERM".to_owned(), "truecolor".to_owned()),
]
.into_iter()
.collect(),
env_is_complete: true,
..Default::default()
};
let original = options.clone();
super::apply_windows_terminal_defaults(&mut options, false);
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options, original);
}
}
@@ -125,21 +141,92 @@ mod tests {
#[test]
fn inherited_grok_override_only_applies_to_incomplete_environments() {
let mut options = nebula_terminal::tty::Options::default();
super::apply_windows_terminal_defaults(&mut options, true);
super::apply_local_console_defaults(&mut options, true);
assert!(!options.env.contains_key(super::GROK_LEGACY_CONSOLE));
// A successful refresh can remove a stale value from the parent's registry
// snapshot. Do not resurrect it when the new complete environment omits it.
options.env_is_complete = true;
super::apply_windows_terminal_defaults(&mut options, true);
super::apply_local_console_defaults(&mut options, true);
assert_eq!(options.env[super::GROK_LEGACY_CONSOLE], "0");
let mut fallback = nebula_terminal::tty::Options::default();
super::apply_windows_terminal_defaults(&mut fallback, false);
super::apply_local_console_defaults(&mut fallback, false);
assert_eq!(fallback.env[super::GROK_LEGACY_CONSOLE], "0");
assert!(!fallback.env_is_complete);
}
#[cfg(windows)]
#[test]
fn complete_explorer_environment_advertises_truecolor_to_the_child_process() {
let mut options = nebula_terminal::tty::Options {
env: std::env::vars()
.filter(|(name, _)| {
!["TERM", "COLORTERM", "WT_SESSION", "TERM_PROGRAM"]
.iter()
.any(|key| name.eq_ignore_ascii_case(key))
})
.collect(),
env_is_complete: true,
..Default::default()
};
options.env.insert("TERM_PROGRAM".into(), "pebrel".into());
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options.env["TERM"], "xterm-256color");
assert_eq!(options.env["COLORTERM"], "truecolor");
assert!(!options.env.contains_key("WT_SESSION"));
// Match the complete-environment child launch, independent of the test
// runner's terminal. Read the variables from the actual child.
let system = std::env::var_os("SystemRoot").unwrap();
let command =
std::path::Path::new(&system).join("System32/WindowsPowerShell/v1.0/powershell.exe");
let output = std::process::Command::new(command)
.args([
"-NoProfile",
"-Command",
"[Console]::WriteLine(\"$env:TERM $env:COLORTERM $env:TERM_PROGRAM\")",
])
.env_clear()
.env("SystemRoot", system)
.envs(&options.env)
.output()
.unwrap();
assert!(
output.status.success(),
"status={}, stderr={}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8(output.stdout).unwrap().trim(),
"xterm-256color truecolor pebrel"
);
}
#[cfg(windows)]
#[test]
fn color_capability_defaults_preserve_explicit_overrides() {
let mut options = nebula_terminal::tty::Options {
env: [
("term", "dumb"),
("ColorTerm", "24bit"),
("NO_COLOR", "1"),
("FORCE_COLOR", "0"),
]
.into_iter()
.map(|(key, value)| (key.into(), value.into()))
.collect(),
env_is_complete: true,
..Default::default()
};
super::apply_local_console_defaults(&mut options, false);
assert_eq!(options.env["term"], "dumb");
assert_eq!(options.env["ColorTerm"], "24bit");
assert_eq!(options.env["NO_COLOR"], "1");
assert_eq!(options.env["FORCE_COLOR"], "0");
assert!(!options.env.contains_key("TERM") && !options.env.contains_key("COLORTERM"));
}
#[cfg(windows)]
#[test]
fn refreshed_local_terminal_preserves_the_pane_grok_override() {
+28 -3
View File
@@ -67,6 +67,30 @@ function Check-UnpreparedProcesses {
}
}
function Wait-RuntimeHelperFiles {
# New helpers have a bounded lifetime, but can still be draining as the
# application exits. Check before setup copies any file. Old stuck helpers
# remain a visible failure; never terminate processes by their image name.
$deadline = [DateTime]::UtcNow.AddSeconds(5)
$helpers = @('runtime\pebrel-hook.exe', 'pebrel-hook.exe',
'runtime\nebula-hook.exe', 'nebula-hook.exe')
while ($true) {
$busy = $null
foreach ($relative in $helpers) {
$path = Join-Path $installation $relative
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }
try {
$probe = [System.IO.FileStream]::new($path, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
$probe.Dispose()
} catch { $busy = $_.Exception }
}
if (-not $busy) { return }
if ([DateTime]::UtcNow -ge $deadline) { throw $busy }
Start-Sleep -Milliseconds 100
}
}
try {
if ((Get-Item -LiteralPath $PlanPath).Length -gt 1048576) { throw 'Update plan exceeds limit' }
$plan = Get-Content -LiteralPath $PlanPath -Raw -Encoding UTF8 | ConvertFrom-Json
@@ -133,6 +157,7 @@ try {
Check-UnpreparedProcesses
# No Restart Manager process-name shutdown: every participant has already
# saved and exited. DIR reuses this validated installation without a chooser.
Wait-RuntimeHelperFiles
$setupLog = Join-Path $transaction 'installer.log'
$arguments = '/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /NOCLOSEAPPLICATIONS /NORESTARTAPPLICATIONS' +
' /DIR="' + $installation + '" /LOG="' + $setupLog + '"'
@@ -144,9 +169,9 @@ try {
throw 'Installed application did not report the expected version'
}
$installedDigest = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash
if ($plan.version -eq $plan.original_version -and $installedDigest -eq $originalDigest) {
throw 'Same-version installation did not replace the application binary'
}
# A repair can replace a previously locked helper while leaving pebrel.exe
# byte-identical. Setup success plus the expected version is authoritative;
# a changed main-executable hash is not a same-version success requirement.
Write-State 'result.json' @{
transaction = $plan.transaction; success = $true; version = $plan.version
executable_sha256 = $installedDigest
+28 -6
View File
@@ -12,14 +12,15 @@
//! surfaces an error banner, 2 even blocks the turn), and kimi's `Stop` is
//! likewise a blockable event. Every path — including panic — must exit 0,
//! fast. Claude and kimi also write the payload to our stdin, so those modes
//! always drain stdin even when the message goes nowhere: an unread pipe
//! could surface as a hook write error.
//! drain stdin within a bounded invocation even when the message goes nowhere.
//! A caller retaining stdin or a stalled receiver must not leave this helper
//! running indefinitely and holding the installed executable open.
//! 2. SCOPED: the hook config is global (settings.json / kimi's config.toml),
//! but the effect must be Nebula-only. The scope guard is the environment:
//! NEBULA_NOTIFY_PIPE only exists for processes spawned inside Nebula.
//! Anywhere else this is an invisible ~10 ms no-op.
//! 3. FAST: pure std, no JSON handling (Nebula parses), one pipe write.
//! Keeps the whole claude→toast chain under ~50 ms.
//! Anywhere else it forwards nothing and exits without affecting the caller.
//! 3. BOUNDED: pure std, no JSON handling (Pebrel parses), one pipe write.
//! Forwarding has a deadline; startup and notification latency depend on the host.
//!
//! Usage (installed by `nebula setup-ai` / Nebula's boot self-heal):
//! ```text
@@ -42,6 +43,7 @@
use std::io::{Read, Write};
const MAX_PAYLOAD_BYTES: usize = 1 << 20;
const FORWARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
/// 其他 agent 的 hook runner 独有的环境变量。
///
@@ -167,7 +169,25 @@ fn log_outcome(source: &str, pane: &str, bytes: usize, outcome: &Outcome) {
fn main() {
// Constraint 1: never leak a failure to the calling CLI.
let args: Vec<String> = std::env::args().skip(1).collect();
let _ = std::panic::catch_unwind(|| run(&args));
let forwarding_args = args.clone();
let (done, finished) = std::sync::mpsc::sync_channel(1);
// Bound the entire forwarding operation, including stdin drain and pipe
// writes. A read-only timeout would still leave a blocked writer alive.
// This one-shot process never joins a stuck worker: returning from main
// retires all of its threads and handles. The provider sees exit code 0.
if std::thread::Builder::new()
.name("hook-forward".into())
.spawn(move || {
let _ = std::panic::catch_unwind(|| run(&forwarding_args));
let _ = done.send(());
})
.is_ok()
{
let _ = finished.recv_timeout(FORWARD_TIMEOUT);
}
// A user-owned notifier is independent of our best-effort transport. Never
// wait for it and do not suppress it when the Pebrel pipe is unavailable.
chain_notifier(&args);
// Cursor 的提交前 Hook 有响应合同;传输失败也不能阻止用户提交。
if args.first().is_some_and(|source| source == "cursor")
&& native_event(&args) == Some("prompt")
@@ -307,7 +327,9 @@ fn run(args: &[String]) {
}
}
log_outcome(source, &pane, payload.len(), &outcome);
}
fn chain_notifier(args: &[String]) {
// Chain mode: keep a pre-existing codex notifier working. Runs even
// outside Nebula — the original program must keep firing everywhere.
let strs: Vec<&str> = args.iter().map(String::as_str).collect();
+142
View File
@@ -0,0 +1,142 @@
//! Real-process lifetime regressions: a provider may retain its stdin handle,
//! and a pipe receiver may stop draining. Neither can pin the installed helper.
use std::io::Write as _;
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
struct Invocation(Option<Child>);
impl Invocation {
fn spawn(args: &[&str], pipe: Option<&str>) -> Self {
let mut command = Command::new(env!("CARGO_BIN_EXE_pebrel-hook"));
command.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
for suffix in ["NOTIFY_PIPE", "REMOTE_HOOK_TOKEN", "HOOK_LOG"] {
command.env_remove(format!("PEBREL_{suffix}"));
command.env_remove(format!("NEBULA_{suffix}"));
}
if let Some(pipe) = pipe {
command.env("PEBREL_NOTIFY_PIPE", pipe);
}
Self(Some(command.spawn().unwrap()))
}
fn finish(mut self) -> Output {
let deadline = Instant::now() + Duration::from_secs(6);
let child = self.0.as_mut().unwrap();
loop {
if child.try_wait().unwrap().is_some() {
let output = self.0.take().unwrap().wait_with_output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
return output;
}
assert!(Instant::now() < deadline, "helper retained a blocked invocation");
std::thread::sleep(Duration::from_millis(10));
}
}
}
impl Drop for Invocation {
fn drop(&mut self) {
if let Some(child) = &mut self.0 {
let _ = child.kill();
let _ = child.wait();
}
}
}
#[test]
fn stdin_kept_open_cannot_pin_a_native_hook_executable() {
for args in [&["codex", "--hooks=full"][..], &["claude"][..]] {
let mut invocation = Invocation::spawn(args, None);
let mut input = invocation.0.as_mut().unwrap().stdin.take().unwrap();
input.write_all(b"{\"hook_event_name\":\"Stop\"}").unwrap();
// Hold stdin open across finish: EOF must not be necessary for exit.
let output = invocation.finish();
assert!(output.stdout.is_empty());
drop(input);
}
}
#[test]
fn cursor_still_allows_submission_when_stdin_never_closes() {
let mut invocation = Invocation::spawn(&["cursor", "--event", "prompt"], None);
let input = invocation.0.as_mut().unwrap().stdin.take().unwrap();
let output = invocation.finish();
assert_eq!(output.stdout, b"{\"continue\":true}\n");
drop(input);
}
#[test]
fn ordinary_completed_payload_exits_silently() {
let mut invocation = Invocation::spawn(&["codex", "--hooks=full"], None);
invocation.0.as_mut().unwrap().stdin.take().unwrap().write_all(b"{}").unwrap();
let output = invocation.finish();
assert!(output.stdout.is_empty() && output.stderr.is_empty());
}
#[test]
fn legacy_notify_still_invokes_the_users_chained_program() {
let executable = std::env::current_exe().unwrap();
let invocation = Invocation::spawn(
&["codex", "--chain", executable.to_str().unwrap(), "--list", "--format=terse"],
None,
);
let output = invocation.finish();
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("ordinary_completed_payload_exits_silently")
);
}
#[cfg(windows)]
#[test]
fn a_receiver_that_never_reads_cannot_pin_the_helper() {
use std::ffi::c_void;
#[link(name = "kernel32")]
unsafe extern "system" {
fn CreateNamedPipeW(
name: *const u16,
mode: u32,
pipe_mode: u32,
instances: u32,
output: u32,
input: u32,
timeout: u32,
security: *const c_void,
) -> *mut c_void;
fn CloseHandle(handle: *mut c_void) -> i32;
fn GetNamedPipeClientProcessId(handle: *mut c_void, pid: *mut u32) -> i32;
}
struct Pipe(*mut c_void);
impl Drop for Pipe {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0);
}
}
}
let name = format!(r"\\.\pipe\pebrel-hook-lifetime-test-{}", std::process::id());
let wide: Vec<u16> = name.encode_utf16().chain(Some(0)).collect();
// The server owns an inbound byte pipe and deliberately never drains it.
let handle =
unsafe { CreateNamedPipeW(wide.as_ptr(), 1, 0, 1, 4096, 4096, 0, std::ptr::null()) };
assert_ne!(handle as isize, -1, "{}", std::io::Error::last_os_error());
let _pipe = Pipe(handle);
let payload = "x".repeat(16 * 1024);
let invocation = Invocation::spawn(&["codex", &payload], Some(&name));
let deadline = Instant::now() + Duration::from_secs(6);
loop {
let mut pid = 0;
if unsafe { GetNamedPipeClientProcessId(handle, &mut pid) } != 0 {
assert_eq!(pid, invocation.0.as_ref().unwrap().id());
break;
}
assert!(Instant::now() < deadline, "helper never connected to the blocked receiver");
std::thread::sleep(Duration::from_millis(10));
}
let output = invocation.finish();
assert!(output.stdout.is_empty() && output.stderr.is_empty());
}
+20 -2
View File
@@ -402,6 +402,22 @@ mod tests {
let event = Event::readable(KEY);
let mut reader = UnblockedReader::new(ScriptedSource { rx, pending: Vec::new() }, 4096);
// Readiness permits another poll; it does not guarantee bytes. piper
// can deliberately yield for fairness and wake us again, and a queued
// notification can outlive the data that originally triggered it.
// Retry only after a notification so a genuinely lost wake still fails.
let read_chunk = |reader: &mut UnblockedReader<ScriptedSource>, buf: &mut [u8]| {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
let read = reader.try_read(buf);
if read > 0 {
return read;
}
assert!(Instant::now() < deadline, "readiness never produced data");
assert!(readable_arrived(&poller, KEY), "pending read was not woken");
}
};
// 首次 register 自带一次无条件投递;先消化掉,后面等到的事件就只可能
// 来自 try_read 的补投。
reader.register(&poller, event, PollMode::Level);
@@ -414,7 +430,8 @@ mod tests {
);
let mut buf = [0u8; 64];
assert_eq!(reader.try_read(&mut buf), 5);
assert_eq!(read_chunk(&mut reader, &mut buf), 5);
assert_eq!(&buf[..5], b"hello");
assert!(reader.pipe.is_empty(), "这一读须把管道读空,否则测不到 TOCTOU 的那一半");
assert!(
@@ -427,6 +444,7 @@ mod tests {
assert_eq!(reader.try_read(&mut buf), 0);
tx.send(b"world".to_vec()).unwrap();
assert!(readable_arrived(&poller, KEY), "重新注册的 waker 失效,后续输出会卡住");
assert_eq!(reader.try_read(&mut buf), 5);
assert_eq!(read_chunk(&mut reader, &mut buf), 5);
assert_eq!(&buf[..5], b"world");
}
}
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Plan the native CI matrices before GitHub creates runner jobs.
The output file is the path normally supplied as ``GITHUB_OUTPUT``. Planning
is deliberately separate from workflow steps: a draft pull request therefore
does not materialize jobs for the scarce platforms.
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence
@dataclass(frozen=True)
class Platform:
runner: str
draft: bool
release_check: bool = False
# One platform catalog owns both draft eligibility and release compilation.
PLATFORMS = (
Platform("ubuntu-24.04", draft=True),
Platform("windows-2022", draft=True),
Platform("macos-26", draft=True, release_check=True),
Platform("windows-11-arm", draft=False),
Platform("macos-26-intel", draft=False, release_check=True),
)
FULL_EVENTS = frozenset(
{"push", "merge_group", "workflow_dispatch", "workflow_call", "schedule"}
)
class PlanError(ValueError):
"""An event cannot be converted into a safe CI plan."""
def _matrices(draft: bool) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
selected = [platform for platform in PLATFORMS if not draft or platform.draft]
return (
[{"os": platform.runner} for platform in selected],
[{"os": platform.runner} for platform in selected if platform.release_check],
)
def _reject_json_constant(value: str) -> None:
raise ValueError(f"non-standard JSON constant {value}")
def _read_event(path: Path) -> dict[str, Any]:
try:
raw = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise PlanError(f"cannot read event payload {path}: {exc}") from exc
try:
payload = json.loads(raw, parse_constant=_reject_json_constant)
except (json.JSONDecodeError, ValueError) as exc:
raise PlanError(f"invalid event JSON in {path}: {exc}") from exc
if not isinstance(payload, dict):
raise PlanError("event payload must be a JSON object")
return payload
def plan_matrices(event_name: str, payload: dict[str, Any]) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""Return native and release matrices for one validated event."""
if event_name == "pull_request":
pull_request = payload.get("pull_request")
if not isinstance(pull_request, dict):
raise PlanError("pull_request event must contain a pull_request object")
draft = pull_request.get("draft")
if not isinstance(draft, bool):
raise PlanError("pull_request.draft must be a boolean")
return _matrices(draft)
if event_name in FULL_EVENTS:
return _matrices(draft=False)
supported = sorted(FULL_EVENTS | {"pull_request"})
raise PlanError(f"unsupported event name {event_name!r}; expected one of {', '.join(supported)}")
def _compact_output(name: str, value: list[dict[str, str]]) -> str:
return f"{name}={json.dumps(value, separators=(',', ':'), ensure_ascii=True)}"
def write_outputs(path: Path, native_matrix: list[dict[str, str]], release_matrix: list[dict[str, str]]) -> None:
"""Append both outputs in one write after planning has fully succeeded."""
content = "\n".join(
(
_compact_output("native_matrix", native_matrix),
_compact_output("release_matrix", release_matrix),
)
) + "\n"
try:
with path.open("a", encoding="utf-8", newline="\n") as output:
output.write(content)
except (OSError, UnicodeError) as exc:
raise PlanError(f"cannot append CI plan to {path}: {exc}") from exc
def _arguments(argv: Sequence[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--event-name", required=True)
parser.add_argument("--event-path", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
arguments = _arguments(argv)
try:
payload = _read_event(arguments.event_path)
native_matrix, release_matrix = plan_matrices(arguments.event_name, payload)
write_outputs(arguments.output, native_matrix, release_matrix)
except PlanError as exc:
print(f"ci_plan: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
+94 -44
View File
@@ -277,13 +277,13 @@ end;
{ —— 按 WSL 发行版注册的右键项 ——
「在 Pebrel 中打开(Ubuntu)」这类菜单项按**安装那一刻**机器上的发行版动态生成:
WSL 发行版子菜单按**安装那一刻**机器上的发行版动态生成:
`Lxss` 里的 `DistributionName` 是唯一真相,枚举口径与 Rust 侧
`shell_detect::find_wsl_distros` 一致(跳过 `docker-desktop*` 这类 plumbing 发行版)。
走 `[Code]` 而不是 `[Registry]` 的原因只有一个:键的条数取决于机器上有几个发行版。
代价是这些键没有 `uninsdeletekey`,卸载由 `RemoveOwnedWslContextMenus` 按名字前缀
加命令串里的可执行文件与 `--shell` 认领后删除。 }
加精确命令与级联菜单归属标记认领后删除。 }
const
LxssRoot = 'Software\Microsoft\Windows\CurrentVersion\Lxss';
@@ -308,67 +308,117 @@ begin
SetArrayLength(Result, Count);
end;
{ 删掉本安装器写下的 WSL 右键项:键名以 `PebrelWsl` 开头、命令串里同时有我们的
可执行文件和 `--shell`,两条都满足才算我们写的。发行版后来被移除也不会留下
点不动的死项。 }
procedure RemoveOwnedWslContextMenus;
{ WSL 右键项按精确可执行文件前缀归属;级联菜单还校验所有子项,保留用户编辑。
发行版移除后,下次安装重建菜单时不留下旧项。 }
function IsOwnedWslCommand(Command, Executable: string): Boolean;
var
Roots: array[0..1] of string;
Root, Key, Command: string;
Names: TArrayOfString;
Index, NameIndex: Integer;
Prefix: string;
begin
Prefix := '"' + Executable + '" --gpui --shell "wsl:';
Result := CompareText(Copy(Command, 1, Length(Prefix)), Prefix) = 0;
end;
function IsOwnedWslMenu(Key, Executable: string): Boolean;
var
Owner, Command: string;
Children, Subkeys: TArrayOfString;
Index: Integer;
begin
Result := False;
if not RegQueryStringValue(HKCU, Key, 'PebrelOwner', Owner) or
(CompareText(Owner, Executable) <> 0) then
Exit;
if not RegGetSubkeyNames(HKCU, Key, Subkeys) then
Exit;
for Index := 0 to GetArrayLength(Subkeys) - 1 do
if CompareText(Subkeys[Index], 'shell') <> 0 then
Exit;
if GetArrayLength(Subkeys) > 0 then begin
if not RegGetSubkeyNames(HKCU, Key + '\shell', Children) then
Exit;
for Index := 0 to GetArrayLength(Children) - 1 do begin
if not RegQueryStringValue(HKCU, Key + '\shell\' + Children[Index] + '\command', '', Command) or
not IsOwnedWslCommand(Command, Executable) then
Exit;
end;
end;
Result := True;
end;
procedure RemoveOwnedWslContextMenusAt(Root, Executable: string);
var
Key, Command: string;
Names: TArrayOfString;
NameIndex: Integer;
begin
Roots[0] := 'Software\Classes\Directory\shell';
Roots[1] := 'Software\Classes\Directory\Background\shell';
for Index := 0 to 1 do begin
Root := Roots[Index];
if RegGetSubkeyNames(HKCU, Root, Names) then
for NameIndex := 0 to GetArrayLength(Names) - 1 do
if Pos('PebrelWsl', Names[NameIndex]) = 1 then begin
Key := Root + '\' + Names[NameIndex];
Command := '';
if RegQueryStringValue(HKCU, Key + '\command', '', Command) and
(Pos(ExpandConstant('{app}\pebrel.exe'), Command) > 0) and
(Pos('--shell', Command) > 0) then
if IsOwnedWslMenu(Key, Executable) or
(RegQueryStringValue(HKCU, Key + '\command', '', Command) and
IsOwnedWslCommand(Command, Executable)) then
if not RegDeleteKeyIncludingSubkeys(HKCU, Key) then
RaiseException('Unable to remove an owned WSL context menu: ' + Key);
end;
end;
procedure RemoveOwnedWslContextMenus;
var
Roots: array[0..1] of string;
Index: Integer;
begin
Roots[0] := 'Software\Classes\Directory\shell';
Roots[1] := 'Software\Classes\Directory\Background\shell';
for Index := 0 to 1 do
RemoveOwnedWslContextMenusAt(Roots[Index], ExpandConstant('{app}\pebrel.exe'));
end;
procedure RegisterWslContextMenuAt(Root, Executable, DirectoryArgument: string;
Distros: TArrayOfString);
var
Index: Integer;
Distro, Verb, Command, Key, Menu: string;
begin
Menu := Root + '\PebrelWslMenu';
{ Never overwrite an unknown installation or an edited submenu. }
if RegKeyExists(HKCU, Menu) and not IsOwnedWslMenu(Menu, Executable) then
RaiseException(FmtMessage(CustomMessage('WslMenuConflict'), [Menu]));
RemoveOwnedWslContextMenusAt(Root, Executable);
if GetArrayLength(Distros) = 0 then
Exit;
if not RegWriteStringValue(HKCU, Menu, 'MUIVerb', CustomMessage('OpenInPebrelWsl')) or
not RegWriteStringValue(HKCU, Menu, 'Icon', Executable + ',0') or
not RegWriteStringValue(HKCU, Menu, 'SubCommands', '') or
not RegWriteStringValue(HKCU, Menu, 'PebrelOwner', Executable) then
RaiseException(CustomMessage('WslMenuRegistrationFailed'));
for Index := 0 to GetArrayLength(Distros) - 1 do begin
Distro := Distros[Index];
{ 键名用序号而不是发行版名:注册表键名里带空格与非 ASCII 只会给自己找麻烦,
何况我们靠前缀认领。 }
Verb := 'PebrelWsl' + IntToStr(Index);
{ `--shell` 必须排在 `--working-directory` 之前:盘根(`D:\`)时后者的收尾
反斜杠会吃掉它的收尾引号,并把后面整段并进同一个参数(issue #36 的另一面),
顺序写反会静默开出一个既没有 cwd、也没用上指定发行版的标签。 }
Command := '"' + Executable + '" --gpui --shell "wsl:' + Distro + '" --working-directory ';
Key := Menu + '\shell\' + Verb;
if not RegWriteStringValue(HKCU, Key, 'MUIVerb', Distro) or
not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or
not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"' + DirectoryArgument + '"') then
RaiseException('Unable to register the WSL context menu for ' + Distro + '.');
end;
end;
procedure RegisterWslContextMenus;
var
Distros: TArrayOfString;
Index: Integer;
Distro, Verb, LabelText, Executable, Command, Key: string;
Executable: string;
begin
{ 先清掉上一次写下的:发行版被移除后,残留的索引键会指向一个已经不存在的发行版。 }
RemoveOwnedWslContextMenus;
Distros := WslDistroNames;
if GetArrayLength(Distros) = 0 then
Exit;
Executable := ExpandConstant('{app}\pebrel.exe');
for Index := 0 to GetArrayLength(Distros) - 1 do begin
Distro := Distros[Index];
{ 键名用序号而不是发行版名:注册表键名里带空格与非 ASCII 只会给自己找麻烦,
何况我们靠前缀认领。 }
Verb := 'PebrelWsl' + IntToStr(Index);
LabelText := CustomMessage('OpenInPebrelWsl') + ' (' + Distro + ')';
{ `--shell` 必须排在 `--working-directory` 之前:盘根(`D:\`)时后者的收尾
反斜杠会吃掉它的收尾引号,并把后面整段并进同一个参数(issue #36 的另一面),
顺序写反会静默开出一个既没有 cwd、也没用上指定发行版的标签。 }
Command := '"' + Executable + '" --gpui --shell "wsl:' + Distro + '" --working-directory ';
Key := 'Software\Classes\Directory\shell\' + Verb;
if not RegWriteStringValue(HKCU, Key, 'MUIVerb', LabelText) or
not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or
not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"%1"') then
RaiseException('Unable to register the WSL context menu for ' + Distro + '.');
Key := 'Software\Classes\Directory\Background\shell\' + Verb;
if not RegWriteStringValue(HKCU, Key, 'MUIVerb', LabelText) or
not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or
not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"%V"') then
RaiseException('Unable to register the WSL background context menu for ' + Distro + '.');
end;
RegisterWslContextMenuAt('Software\Classes\Directory\shell', Executable, '%1', Distros);
RegisterWslContextMenuAt('Software\Classes\Directory\Background\shell', Executable, '%V', Distros);
end;
procedure MigrateLegacyIntegrations;
+6 -2
View File
@@ -77,7 +77,9 @@ english.AutoStart=Start Pebrel when I sign in to Windows
english.InstallFont=Install Maple Mono font for the current user
english.AddToPath=Add Pebrel to the user PATH
english.OpenInPebrel=Open in Pebrel
english.OpenInPebrelWsl=Open in Pebrel
english.OpenInPebrelWsl=Open in Pebrel (WSL)
english.WslMenuConflict=An existing WSL submenu belongs to another installation or was edited, so it was preserved: %1.
english.WslMenuRegistrationFailed=Unable to register the WSL context submenu.
english.LaunchProgram=Launch Pebrel
english.UninstallProgram=Uninstall Pebrel
english.CloseLegacyProgram=Close the application at %1, then retry the installation.
@@ -89,7 +91,9 @@ chinesesimplified.AutoStart=登录 Windows 后启动 Pebrel
chinesesimplified.InstallFont=为当前用户安装 Maple Mono 字体
chinesesimplified.AddToPath=将 Pebrel 添加到当前用户 PATH
chinesesimplified.OpenInPebrel=在 Pebrel 中打开
chinesesimplified.OpenInPebrelWsl=在 Pebrel 中打开
chinesesimplified.OpenInPebrelWsl=在 Pebrel 中打开(WSL)
chinesesimplified.WslMenuConflict=现有 WSL 子菜单属于其他安装或已被修改,因此已保留:%1。
chinesesimplified.WslMenuRegistrationFailed=无法注册 WSL 右键子菜单。
chinesesimplified.LaunchProgram=启动 Pebrel
chinesesimplified.UninstallProgram=卸载 Pebrel
chinesesimplified.CloseLegacyProgram=请关闭 %1 中运行的程序,然后重试安装。
+4
View File
@@ -17,7 +17,11 @@ class UpdateProcess {
if (!File.Exists(Path.Combine(directory, "parent-exited"))) return 3;
if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_FAIL_INSTALL") == "1") return 17;
if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_NOOP_INSTALL") == "1") return 0;
try {
File.Copy(Path.Combine(directory, "candidate.exe"), Path.Combine(target, "pebrel.exe"), true);
Directory.CreateDirectory(Path.Combine(target, "runtime"));
File.WriteAllText(Path.Combine(target, "runtime", "pebrel-hook.exe"), "repaired helper fixture");
} catch (IOException) { return 5; }
return 0;
#else
if (args.Length > 0 && args[0] == "--version") {
+70 -1
View File
@@ -1,4 +1,4 @@
#ifndef FixtureRoot
#ifndef FixtureRoot
#error FixtureRoot must point at an isolated directory under the workspace tmp directory.
#endif
#define MigrationFixture
@@ -15,6 +15,11 @@ OutputBaseFilename=installer-migration-fixture
Compression=none
SetupLogging=no
[CustomMessages]
OpenInPebrelWsl=Open in Pebrel (WSL)
WslMenuConflict=Preserved an edited or foreign submenu: %1.
WslMenuRegistrationFailed=Unable to register the WSL context submenu.
[Code]
#include "..\installer-migration.iss"
@@ -180,6 +185,69 @@ begin
Check(FileExists(NewLink), 'shortcut pointing at another program is preserved');
end;
procedure CheckContextMenus(Root: string);
var
RegistryRoot, ShellRoot, Executable, Value, Menu, ForeignCommand: string;
Distros, Names: TArrayOfString;
Index: Integer;
Failed: Boolean;
begin
RegistryRoot := 'Software\PebrelTestFixtures\' + ExtractFileName(Root);
Executable := Root + '\Program Files\pebrel.exe';
ForeignCommand := '"D:\other\pebrel.exe" --gpui --shell "wsl:Other" --working-directory "%1"';
try
for Index := 0 to 1 do begin
ShellRoot := RegistryRoot + '\root' + IntToStr(Index);
Menu := ShellRoot + '\PebrelWslMenu';
Check(RegWriteStringValue(HKCU, ShellRoot + '\Pebrel\command', '', 'ordinary-open'), 'seed ordinary entry');
Check(RegWriteStringValue(HKCU, ShellRoot + '\PebrelWslForeign\command', '', ForeignCommand), 'seed foreign entry');
Check(RegWriteStringValue(HKCU, ShellRoot + '\PebrelWsl0\command', '',
'"' + Executable + '" --gpui --shell "wsl:Old" --working-directory "%1"'), 'seed old flat entry');
SetArrayLength(Distros, 3);
Distros[0] := 'Debian';
Distros[1] := 'Ubuntu Test';
Distros[2] := '开发环境';
if Index = 0 then Value := '%1' else Value := '%V';
RegisterWslContextMenuAt(ShellRoot, Executable, Value, Distros);
Check(not RegKeyExists(HKCU, ShellRoot + '\PebrelWsl0'), 'migrate old flat entry');
Check(RegGetSubkeyNames(HKCU, ShellRoot, Names) and (GetArrayLength(Names) = 3), 'only normal, cascade and foreign roots remain');
Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 3), 'three distros live inside cascade');
Check(RegQueryStringValue(HKCU, Menu, 'SubCommands', Value) and (Value = ''), 'enable static cascade');
Check(RegQueryStringValue(HKCU, Menu + '\shell\PebrelWsl1\command', '', Value), 'child command exists');
if Index = 0 then
Check(Value = '"' + Executable + '" --gpui --shell "wsl:Ubuntu Test" --working-directory "%1"', 'selected directory argv')
else
Check(Value = '"' + Executable + '" --gpui --shell "wsl:Ubuntu Test" --working-directory "%V"', 'background directory argv');
Check(RegQueryStringValue(HKCU, Menu + '\shell\PebrelWsl2', 'MUIVerb', Value) and (Value = '开发环境'), 'Unicode label retained');
RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros);
Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 3), 'repeat registration has no duplicates');
SetArrayLength(Distros, 1);
RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros);
Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 1), 'removed distros leave no stale entries');
Check(RegWriteStringValue(HKCU, Menu + '\shell\Custom\command', '', ForeignCommand), 'seed edited submenu');
Failed := False;
try
RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros);
except
Failed := True;
end;
Check(Failed and RegKeyExists(HKCU, Menu + '\shell\Custom'), 'edited submenu is not overwritten');
RemoveOwnedWslContextMenusAt(ShellRoot, Executable);
Check(RegKeyExists(HKCU, Menu + '\shell\Custom'), 'uninstall preserves edited subtree');
Check(RegDeleteKeyIncludingSubkeys(HKCU, Menu + '\shell\Custom'), 'remove fixture edit');
RemoveOwnedWslContextMenusAt(ShellRoot, 'D:\other\install.exe');
Check(RegKeyExists(HKCU, Menu), 'other installation cannot remove this menu');
SetArrayLength(Distros, 0);
RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros);
Check(not RegKeyExists(HKCU, Menu), 'zero distros removes empty cascade');
Check(RegQueryStringValue(HKCU, ShellRoot + '\PebrelWslForeign\command', '', Value) and (Value = ForeignCommand), 'foreign entry preserved');
Check(RegQueryStringValue(HKCU, ShellRoot + '\Pebrel\command', '', Value) and (Value = 'ordinary-open'), 'ordinary entry preserved');
end;
finally
RegDeleteKeyIncludingSubkeys(HKCU, RegistryRoot);
end;
end;
function InitializeSetup: Boolean;
var
Root, Report: string;
@@ -190,6 +258,7 @@ begin
CheckPayloadMigration(Root);
CheckFailures(Root);
CheckShortcuts(Root);
CheckContextMenus(Root);
Report := 'PASS: ' + IntToStr(Checks) + ' migration checks';
except
Report := 'FAIL: ' + GetExceptionMessage;
+47
View File
@@ -11,6 +11,48 @@ from scripts.ci_native_tests import main, native_commands
class NativeSuiteTests(unittest.TestCase):
def test_required_lint_plans_runners_before_native_jobs(self):
root = Path(__file__).resolve().parents[2]
workflow = (root / ".github/workflows/linux-lua.yml").read_text()
lint = workflow.split("\n lint:\n", 1)[1].split("\n native-tests:\n", 1)[0]
self.assertIn("name: lint", lint)
self.assertIn("python scripts/ci_plan.py", lint)
self.assertIn('--event-path "$GITHUB_EVENT_PATH"', lint)
self.assertLess(lint.index("cargo fmt"), lint.index("python scripts/ci_plan.py"))
for job, output in (("native-tests", "native_matrix"),
("macos-release-check", "release_matrix")):
body = workflow.split(f"\n {job}:\n", 1)[1]
body = re.split(r"\n [a-z][a-z-]*:\n", body, maxsplit=1)[0]
self.assertIn("needs: lint", body)
self.assertIn(f"fromJSON(needs.lint.outputs.{output})", body)
self.assertNotIn("pull_request.draft", body)
self.assertNotIn("matrix.tier", body)
self.assertIn("cargo check --locked --workspace --release", workflow)
# Native validation never needs to retain checkout credentials.
checkouts = re.findall(r"- uses: actions/checkout@[^\n]+\n(.*?)(?= - |\Z)",
workflow, re.S)
self.assertTrue(checkouts)
for checkout in checkouts:
self.assertIn("persist-credentials: false", checkout)
def test_packages_run_after_merge_or_manual_dispatch_not_for_prs(self):
root = Path(__file__).resolve().parents[2]
workflow = (root / ".github/workflows/preview-packages.yml").read_text()
events = workflow.split("\non:\n", 1)[1].split("\nconcurrency:", 1)[0]
triggers = set(re.findall(r"^ ([a-z_]+):", events, re.M))
self.assertEqual(triggers, {"push", "workflow_dispatch"})
push = re.search(r"^ push:(.*?)(?=^ [a-z_]+:|\Z)", events, re.M | re.S)
self.assertIsNotNone(push)
self.assertIn("branches: [main]", push.group(1))
self.assertIn("paths:", push.group(1))
# Packaging still needs explicit dispatch to create a public release.
self.assertIn("github.event_name == 'workflow_dispatch' && inputs.publish == true", workflow)
stable = (root / ".github/workflows/release.yml").read_text()
stable_events = stable.split("\non:\n", 1)[1].split("\nconcurrency:", 1)[0]
self.assertEqual(set(re.findall(r"^ ([a-z_]+):", stable_events, re.M)),
{"push", "workflow_dispatch"})
self.assertIn('tags: ["v*.*.*"]', stable_events)
def test_every_pr_and_merge_group_runs_without_path_exclusions(self):
root = Path(__file__).resolve().parents[2]
workflow = (root / ".github/workflows/linux-lua.yml").read_text()
@@ -20,6 +62,11 @@ class NativeSuiteTests(unittest.TestCase):
self.assertIsNotNone(declaration)
self.assertNotIn("paths", declaration.group(1))
self.assertNotIn("branches", declaration.group(1))
if event == "pull_request":
# ready_for_review starts the scarce-runner jobs that draft
# pushes skip; the default activity types must stay listed.
self.assertIn("types: [opened, synchronize, reopened, ready_for_review]", declaration.group(1))
else:
self.assertNotIn("types", declaration.group(1))
self.assertIn("branches: [main]", events)
self.assertNotIn("branches-ignore", events)
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "ci_plan.py"
class CiPlanCliTests(unittest.TestCase):
def run_cli(self, event_name: str, payload: object, output: Path) -> subprocess.CompletedProcess[str]:
event_path = output.parent / "event.json"
event_path.write_text(json.dumps(payload), encoding="utf-8")
return subprocess.run(
[
sys.executable,
str(SCRIPT),
"--event-name",
event_name,
"--event-path",
str(event_path),
"--output",
str(output),
],
text=True,
capture_output=True,
check=False,
)
@staticmethod
def outputs(path: Path) -> dict[str, object]:
lines = path.read_text(encoding="utf-8").splitlines()
return {name: json.loads(value) for name, value in (line.split("=", 1) for line in lines)}
def test_draft_pr_does_not_materialize_scarce_runner_rows(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "github-output"
result = self.run_cli("pull_request", {"pull_request": {"draft": True}}, output)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
output.read_text(encoding="utf-8"),
'native_matrix=[{"os":"ubuntu-24.04"},{"os":"windows-2022"},{"os":"macos-26"}]\n'
'release_matrix=[{"os":"macos-26"}]\n',
)
plan = self.outputs(output)
self.assertNotIn({"os": "windows-11-arm"}, plan["native_matrix"])
self.assertNotIn({"os": "macos-26-intel"}, plan["native_matrix"])
def test_ready_pr_has_all_native_and_release_checks(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "github-output"
result = self.run_cli("pull_request", {"pull_request": {"draft": False}}, output)
self.assertEqual(result.returncode, 0, result.stderr)
plan = self.outputs(output)
self.assertEqual(
[row["os"] for row in plan["native_matrix"]],
["ubuntu-24.04", "windows-2022", "macos-26", "windows-11-arm", "macos-26-intel"],
)
self.assertEqual(
[row["os"] for row in plan["release_matrix"]],
["macos-26", "macos-26-intel"],
)
def test_success_appends_outputs_without_replacing_existing_lines(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "github-output"
output.write_text("existing=preserved\n", encoding="utf-8")
result = self.run_cli("push", {}, output)
self.assertEqual(result.returncode, 0, result.stderr)
lines = output.read_text(encoding="utf-8").splitlines()
self.assertEqual(lines[0], "existing=preserved")
self.assertTrue(lines[1].startswith("native_matrix=[{"))
self.assertTrue(lines[2].startswith("release_matrix=[{"))
def test_merge_group_and_non_pr_events_have_full_coverage(self) -> None:
events = ("merge_group", "push", "workflow_dispatch", "workflow_call", "schedule")
with tempfile.TemporaryDirectory() as directory:
for event_name in events:
with self.subTest(event_name=event_name):
output = Path(directory) / f"{event_name}.out"
result = self.run_cli(event_name, {}, output)
self.assertEqual(result.returncode, 0, result.stderr)
plan = self.outputs(output)
self.assertEqual(
[row["os"] for row in plan["native_matrix"]],
["ubuntu-24.04", "windows-2022", "macos-26", "windows-11-arm", "macos-26-intel"],
)
self.assertEqual(
[row["os"] for row in plan["release_matrix"]],
["macos-26", "macos-26-intel"],
)
def test_invalid_events_fail_without_matrix_outputs(self) -> None:
cases = (
("pull_request", {}, "missing pull request"),
("pull_request", {"pull_request": []}, "wrong pull request type"),
("pull_request", {"pull_request": {}}, "missing draft"),
("pull_request", {"pull_request": {"draft": "false"}}, "wrong draft type"),
("pull_request", {"pull_request": {"draft": 0}}, "integer draft type"),
("unknown", {}, "unknown event"),
("push", [], "non-object payload"),
)
with tempfile.TemporaryDirectory() as directory:
for event_name, payload, label in cases:
with self.subTest(label=label):
output = Path(directory) / f"{label.replace(' ', '-')}.out"
output.write_text("existing=preserved\n", encoding="utf-8")
result = self.run_cli(event_name, payload, output)
self.assertNotEqual(result.returncode, 0)
self.assertIn("ci_plan:", result.stderr)
self.assertEqual(output.read_text(encoding="utf-8"), "existing=preserved\n")
def test_malformed_json_fails_without_creating_output(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
event_path = root / "broken.json"
output = root / "github-output"
event_path.write_text('{"pull_request":', encoding="utf-8")
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--event-name",
"pull_request",
"--event-path",
str(event_path),
"--output",
str(output),
],
text=True,
capture_output=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(output.exists())
def test_non_standard_json_constant_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
event_path = root / "non-standard.json"
output = root / "github-output"
event_path.write_text('{"value":NaN}', encoding="utf-8")
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--event-name",
"push",
"--event-path",
str(event_path),
"--output",
str(output),
],
text=True,
capture_output=True,
check=False,
)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(output.exists())
if __name__ == "__main__":
unittest.main()
+16 -2
View File
@@ -16,6 +16,7 @@ from scripts.stable_release import (
)
from scripts.preview_release import sha256
from scripts.preview_release import MIN_ASSET_SIZE
from scripts.ci_plan import plan_matrices
VERSION = "1.6.0"
@@ -78,8 +79,21 @@ class StableReleaseTests(unittest.TestCase):
self.assertNotIn(" native-tests:\n", workflow)
self.assertNotIn("uses: ./.github/workflows/linux-lua.yml", workflow)
shared = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8")
for platform in ("ubuntu-24.04", "windows-2022", "windows-11-arm", "macos-26", "macos-26-intel"):
self.assertIn(platform, shared)
# Platform coverage comes from the event plan, not literal runner names
# in YAML. Verify both the consumer wiring and the full caller matrices.
for output in ("native_matrix", "release_matrix"):
self.assertIn(f"fromJSON(needs.lint.outputs.{output})", shared)
self.assertIn("runs-on: ${{ matrix.os }}", shared)
for event in ("push", "merge_group", "workflow_call", "workflow_dispatch"):
with self.subTest(event=event):
native, release = plan_matrices(event, {})
self.assertCountEqual(
[row["os"] for row in native],
["ubuntu-24.04", "windows-2022", "windows-11-arm", "macos-26", "macos-26-intel"],
)
self.assertCountEqual(
[row["os"] for row in release], ["macos-26", "macos-26-intel"],
)
for trigger in ("pull_request:", "merge_group:", "branches: [main]"):
self.assertIn(trigger, shared)
self.assertIn("run: python scripts/ci_native_tests.py", shared)
+28 -7
View File
@@ -36,7 +36,7 @@ function Write-Json([string]$Path, $Value) {
}
$results = @()
foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'reinstall-noop')) {
foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) {
$directory = Join-Path $OutputRoot $scenario
$installation = Join-Path $directory 'installed app'
$transaction = Join-Path $directory 'transaction'
@@ -44,17 +44,25 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
$null = New-Item -ItemType Directory -Path $installation, $transaction, $config -Force
$executable = Join-Path $installation 'pebrel.exe'
Copy-Item -LiteralPath $old -Destination $executable
$payload = if ($scenario -eq 'reinstall') { $reinstall } else { $candidate }
$payload = if ($scenario -eq 'reinstall') { $reinstall } elseif ($scenario -eq 'repair-identical') { $old } else { $candidate }
Copy-Item -LiteralPath $payload -Destination (Join-Path $directory 'candidate.exe')
[System.IO.File]::WriteAllText((Join-Path $installation 'unins000.exe'), 'fixture marker')
$env:PEBREL_HANDOFF_FIXTURE = $directory
$env:PEBREL_HANDOFF_FAIL_INSTALL = if ($scenario -eq 'installer-failure') { '1' } else { '0' }
$env:PEBREL_HANDOFF_NOOP_INSTALL = if ($scenario -eq 'reinstall-noop') { '1' } else { '0' }
$env:PEBREL_HANDOFF_NOOP_INSTALL = if ($scenario -eq 'upgrade-noop') { '1' } else { '0' }
$parent = Start-Process -FilePath $executable -ArgumentList 'wait' -PassThru
$null = $parent.Handle
$runner = $null
$other = $null
$helperLock = $null
try {
if ($scenario -in @('helper-unlocked', 'helper-held')) {
$null = New-Item -ItemType Directory -Path (Join-Path $installation 'runtime') -Force
$helperPath = Join-Path $installation 'runtime\pebrel-hook.exe'
[System.IO.File]::WriteAllText($helperPath, 'old helper fixture')
$helperLock = [System.IO.FileStream]::new($helperPath, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
}
$plan = @{
schema = 1; transaction = $scenario; executable = $executable; installation = $installation
config_directory = $config; installer = $installer
@@ -64,7 +72,7 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
participants = @(@{ pid = $parent.Id; created = $parent.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() })
}
if ($scenario -eq 'checksum') { $plan.sha256 = '0' * 64 }
if ($scenario -in @('reinstall', 'reinstall-noop')) { $plan.version = '1.8.0' }
if ($scenario -in @('reinstall', 'repair-identical')) { $plan.version = '1.8.0' }
if ($scenario -eq 'creation-time') { $plan.participants[0].created = '1' }
if ($scenario -eq 'other-process') {
$other = Start-Process -FilePath $executable -ArgumentList 'wait-other' -PassThru
@@ -104,8 +112,14 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran while the old process was alive'
[System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit')
Assert ($parent.WaitForExit(5000)) 'Fixture parent did not exit'
if ($scenario -eq 'helper-unlocked') {
Start-Sleep -Milliseconds 350
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup started with the helper still locked'
$helperLock.Dispose()
$helperLock = $null
}
Wait-File $resultPath
if ($scenario -in @('success', 'installer-failure', 'reinstall', 'reinstall-noop')) {
if ($scenario -in @('success', 'installer-failure', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) {
Wait-File (Join-Path $directory 'new-launch')
$launch = [System.IO.File]::ReadAllLines((Join-Path $directory 'new-launch'))
Assert ($launch[0] -eq $executable) 'Relaunch selected another installation'
@@ -117,24 +131,31 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
}
Assert ($runner.WaitForExit(5000)) 'Helper did not finish'
$result = Get-Content -LiteralPath $resultPath -Raw -Encoding UTF8 | ConvertFrom-Json
Assert ($result.success -eq ($scenario -in @('success', 'reinstall'))) 'Unexpected helper outcome'
Assert ($result.success -eq ($scenario -in @('success', 'reinstall', 'repair-identical', 'helper-unlocked'))) 'Unexpected helper outcome'
if ($result.success) {
$expected = (Get-FileHash -LiteralPath $payload -Algorithm SHA256).Hash
Assert ($result.executable_sha256 -eq $expected) 'Installed binary differs from candidate'
Assert (Test-Path -LiteralPath (Join-Path $installation 'runtime\pebrel-hook.exe')) 'Successful setup did not repair the helper'
Assert ([System.IO.File]::ReadAllText((Join-Path $installation 'runtime\pebrel-hook.exe')) -eq 'repaired helper fixture') 'Helper still contains the original payload'
}
if ($scenario -in @('other-process', 'late-process')) {
Assert (-not $other.HasExited) 'Update stopped an unprepared process'
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran with an unprepared process'
}
if ($scenario -in @('installer-failure', 'reinstall-noop')) {
if ($scenario -in @('installer-failure', 'upgrade-noop', 'helper-held')) {
Assert $result.recovered_original 'Untouched old executable was not recovered'
}
if ($scenario -eq 'helper-held') {
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'A blocked helper allowed a partial installation'
Assert ($result.error -match 'pebrel-hook.exe') 'The blocked file is missing from the failure details'
}
$probe = [System.IO.FileStream]::new($plan.guard_path, [System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
$probe.Dispose()
$results += @{ scenario = $scenario; passed = $true }
Write-Output "$scenario : passed"
} finally {
if ($helperLock) { $helperLock.Dispose() }
if ($runner -and -not $runner.HasExited) { $runner.Kill(); $runner.WaitForExit() }
if (-not $parent.HasExited) {
[System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit')
@@ -0,0 +1,69 @@
# 用户反馈回归测试清单
本清单覆盖 2026-09-23 汇总的 Hook、更新、窗口材质、链接、Codex 显示与
Windows 右键菜单反馈。它是验收合同,不表示下列功能已经全部修复。
`待验证`、`等待日志`、`部分覆盖` 均不得作为 Issue 已解决的依据。
## 执行与证据
- 每轮记录源码提交、未提交补丁、产品版本、Windows build、DPI、主题、字体、
Shell 与 Codex 版本。记录实际命令、通过/失败数量及截图或日志位置。
- 自动回归使用实际生产函数、真实终端网格或进程夹具;不能只检查源码中出现
某个字符串。原生截图不替代行为测试,编译成功也不替代视觉验收。
- 对修复先验证旧实现失败,再验证新实现通过;保留相邻功能的反例。
- 浅色/深色、100%/125%/150%/200% DPI、宽/窄窗口、分屏、滚动和主题热切换
按条目覆盖。平台条件导致无法执行的项必须注明,不计入通过数量。
- 实机操作使用隔离配置与测试窗口。安装/注册表夹具只修改其临时测试根,
不关闭用户会话,不覆盖正式安装,不触碰用户 Hook、凭据和发行版配置。
- 仅关闭已有代码与验收证据覆盖的问题单;部分修复说明剩余范围。
发布版本尚未包含修复时必须说明交付状态,不把本地通过写成已发布。
## 回归项目
| ID | 问题与来源 | 自动回归 / 通过条件 | 实机验收 / 反例 | 当前证据 |
| --- | --- | --- | --- | --- |
| HOOK-255 | [#255](https://github.com/Kuddev/pebrel/issues/255):Windows Codex Hook 被 PowerShell 解析为字符串而未执行 | 生成命令实际执行;`codex`、`--hooks=full/turns` 和 stdin JSON 完整传入;带空格、单引号、`$`、反引号的路径保持字面值 | Windows PowerShell 5.1 与 PowerShell 7;Codex 0.155.1/0.156.0;新会话、工具调用、提问、停止都无 ParserError | Windows 5.1 进程夹具通过;完整 Codex/PowerShell 7 会话待验收 |
| HOOK-LIFETIME | #258 前置故障:关闭 Pebrel 后 Hook 仍占用安装文件 | 保持 stdin 打开、连接后不读取命名管道时,真实 Hook 子进程仍在期限内以 0 退出;Cursor 继续允许提交;用户原有 notifier 仍执行 | 已运行的旧版 Hook 不会自动获得新期限;分别验证新启动的 native Hook 与旧进程恢复步骤 | 7 项单元 + 5 项真实进程回归通过 |
| HOOK-MIGRATE | 更新后旧 `hooks.json` / 归属标记仍含失效命令 | 旧受管命令迁移;重复安装幂等;保留 notify、第三方 Hook、显式禁用及用户编辑;卸载只移除自有条目 | 重启 Pebrel 后自动修复;用户修改过的冲突条目不能静默覆盖 | `ai_hook::` 120 通过、1 项既有忽略;后续改动需重跑相关项 |
| UPDATE-403 | 手动检查更新返回 HTTP 403 | 公共 Release 无需用户 token;覆盖 403/429、正常 JSON、其他错误、代理、受限响应;下载仍须官方 URL、精确资产名和 SHA-256 | 同一网络直连/代理、启动检查/手动检查;离线不得假报最新或成功 | **等待日志,按用户要求暂停**;未验收、不结项 |
| UPDATE-EXIT5 | 安装器 `exit code 5`,当前/最新版本显示相同 | 失败不得标成功;保留原快照与安装日志;已退出进程、安装失败、部分替换、相同版本、校验失败分别验证 | 基于反馈者 `installer.log` 重现;核对原版本、目标版本、实际 EXE;不能把 Inno 退出码 5 当作 Win32 拒绝访问码 | 后续 #258 已提供修复安装成功日志;无限等待的 Hook 已补期限;安装前等待短暂占用释放,持续占用时在启动安装器前失败,避免部分替换;旧版本已有挂起进程仍需另行处理 |
| UPDATE-258 | [#258](https://github.com/Kuddev/pebrel/issues/258):同版本修复成功却因主程序哈希不变报错 | 同版本主程序字节不变、Hook 文件修复后应成功;安装器失败或目标版本仍错误必须失败 | 同版本重装、首次升级部分成功后的修复;日志、实际版本和结果提示一致;原会话快照保留 | 12 项原生 handoff 场景通过,包含 `repair-identical` 与 `upgrade-noop` 正反例 |
| BLUR-236-A | [#236](https://github.com/Kuddev/pebrel/issues/236):Acrylic 在屏幕边缘透出背景内容 | 切换材质/透明度不重复创建资源;新窗口、关闭、失焦、最大化/还原、队列退出及运行库失败路径 | 0% 与非零不透明度;明显文字/色块背景;左右/上下屏幕边缘、最大化、任务栏恢复、混合 DPI;失焦不出现不透明灰底 | [PR #250](https://github.com/Kuddev/pebrel/pull/250) 尚未合并;其跨平台 CI 通过不等于本轮实机验收 |
| BLUR-236-B | #236 的 Aero 与 Acrylic 兼容回退范围 | Windows 10、Win11 22H2+、缺失/不匹配运行库与失败挂接均可正常启动/切换/退出 | Aero 需单独验收;缺少受支持 Windows App Runtime 时旧边缘现象不能标为已解决 | #250 仅覆盖受支持 Acrylic;**Aero 和回退范围仍开放** |
| LINK-CJK | OSC 8 中文/全角字符的虚线只画半字、错位 | 从真实 VT 输出构建网格,中文两格、空格、ASCII、组合字符均按列覆盖;逐格绘制与连续区间的物理像素集合相同 | `A开始 菜单Z`、纯中文、混排;DPI/分屏/窄窗/换行;不随字体回补或字形分段重置虚线 | 真实 OSC 8 网格与 16 种 DPI/格宽组合像素覆盖回归通过;原生 Nord / Paper 混排截图已核对 |
| LINK-PATH | 英文路径下划线断开,带空格路径只匹配前半段 | 连续虚线相位;引号包围的盘符/UNC/`~/` 路径完整匹配;hover、点击使用同一边界;普通正文不误连 | `"C:\Program Files\Example Tools\probe.exe"`、中文与括号目录、相邻命令参数、路径换行 | 8 项 hint 测试与 5 项配置解析测试通过;原生带空格路径截图已核对 |
| LINK-PROMPT | WSL `user@host:~/.codex$` 吞入 `$`;绝对路径提示符没有下划线 | `~/` 与 `/mnt/d/...` 提示符中的 `$`/`#` 不可点;真实文件名 `~/cost$`、URL 尾部 `$`、路径内部 `$` 保留;下划线与命中边界一致 | Bash/WSL 彩色提示符、root 提示符、输入命令后、换行;OSC 8 显式 URI 不被启发式改写 | 正反匹配/命中回归通过;绝对路径打开应使用所属 WSL 发行版,不得套用宿主目录或 SSH 目标 |
| CODEX-SPARKLE | 对照终端 中可见的 Codex 星点在 Pebrel 不显示 | 核对真彩色能力、OSC 10/11 回答、盲文点字与前/背景色;真实 PTY 录制/回放;关闭动画时不强制产生动画 | 相同 Codex 可执行文件/模型/设置,对照 WT;分别覆盖 low/high/xhigh/max/ultra、Max→xhigh 回退、持续星点与一次性等级动画;以 CLI 实际输出为准,不能由终端自行按等级添加/过滤星点;不能伪造 `WT_SESSION` 来冒充 WT | 完整 Windows 子环境漏真彩色声明已修复;8 项环境回归、10 项身份/WSL 透传回归及 15 主题 VT 回放通过;真实产品星点字形/背景截图已核对;已观察到 Codex 0.154.0 在 xhigh 持续输出变化的星点;其源码持续星点与 Max/Ultra 一次性动画分别控制;与对照终端的等级切换差异仍待验收 |
| CODEX-MESSAGE | **所有主题**的 Codex 用户消息与 AI 回复背景缺少区分 | 保留/正确映射应用给出的用户消息背景;未提供背景时不得凭屏幕文字猜测角色;消息文本对比度不退化 | 遍历主题注册表中的全部内置主题;输入框与已发送的单/多行、中英混排消息;深浅切换、滚动回看;用户消息应有可辨认浅色背景区 | 15 个内置主题 + 225 种主题切换的背景保留回归通过;真实产品 Nord / Paper 的背景和星点截图已核对;其余主题有自动回放覆盖,仍需原生视觉抽查 |
| SHELL-MENU | 多个 WSL 发行版把资源管理器右键菜单铺满 | 保留一个普通“在 Pebrel 中打开”;其余发行版集中到一个级联菜单;零/一/多个发行版、重复安装、移除发行版、卸载与旧项迁移 | 文件夹对象/文件夹空白处;中文/空格/盘根路径;点击普通项与每个子项验证 Shell 和 cwd;保留第三方/其他安装拥有的键 | 已实现;真实 Inno 夹具累计 117 项通过,完整安装器编译通过;正式 Explorer 点击验收仍需进行 |
## 修复与评审入口
- Hook 命令及退出期限:[PR #260](https://github.com/Kuddev/pebrel/pull/260),关联 #254 / #255 及 #258 的前置占用故障。
- 同版本修复判定:[PR #261](https://github.com/Kuddev/pebrel/pull/261),关联 #258。
- 下划线与路径边界:[PR #262](https://github.com/Kuddev/pebrel/pull/262)。
- Windows / WSL 真彩色能力:[PR #263](https://github.com/Kuddev/pebrel/pull/263),关联 #136。
- WSL 级联菜单:[PR #264](https://github.com/Kuddev/pebrel/pull/264)。
本地组合回归与各 PR 的 CI、Code Owner 评审是不同证据;未合并、未发布不能写成已经交付。
## 当前测试入口
```powershell
cargo test --locked -p nebula_hook
cargo test --locked -p nebula --bin pebrel --features gpui-shell platform::environment::tests
cargo test --locked -p nebula --bin pebrel --features gpui-shell ai_hook::
cargo test --locked -p nebula --bin pebrel --features gpui-shell display::hint::tests
cargo test --locked -p nebula --bin pebrel --features gpui-shell config::ui_config::tests
cargo test --locked -p nebula --bin pebrel --features gpui-shell gpui_shell::terminal::link_underline::tests
cargo test --locked -p nebula --bin pebrel --features gpui-shell gpui_shell::terminal::element::tests
cargo test --locked -p nebula --bin pebrel --features gpui-shell display::terminal_color::tests
```
安装器既有入口为 `scripts/tests/installer.tests.ps1`、
`scripts/tests/installer-migration.tests.ps1`、`scripts/tests/test_update_handoff.ps1`。
菜单夹具已扩展零/一/多个发行版及所有权保护;handoff 已扩展同版本修复正例与错误版本反例。它们不代替正式安装或 Explorer 点击验收。
修改完成后还需按 `CONTRIBUTING.md` 执行架构、格式、受影响模块与真实产品检查;
架构门禁使用实际目标分支基线。新增门禁或 GitHub 强制保护需单独验证,
本清单不宣称启用了服务端保护。