Merge pull request #241 from Kuddev/release/1.9.0-final

Prepare Pebrel 1.9.0 reader updates and native Windows ARM64 release
This commit is contained in:
Kuddev authored and GitHub committed 2026-09-22 22:41:30 +08:00
commit 0110c7c139
62 files changed
+1327 -277

No files matched your search

+82 -12
View File
@@ -26,7 +26,7 @@ on:
type: string
default: ""
validated_run:
description: Existing stable run whose Windows native tests passed for source_commit
description: Existing stable or Full native tests run whose Windows tests passed for source_commit
type: string
default: ""
macos_signing:
@@ -119,15 +119,6 @@ jobs:
bash -n scripts/package-linux.sh scripts/package-macos.sh
python3 -m py_compile scripts/stable_release.py scripts/preview_release.py
native-tests:
name: Native tests
permissions:
contents: read
actions: read
if: ${{ !inputs.windows_only }}
needs: prepare
uses: ./.github/workflows/linux-lua.yml
linux:
name: Linux x86_64 stable package
if: ${{ !inputs.windows_only }}
@@ -543,10 +534,89 @@ jobs:
if-no-files-found: warn
retention-days: 14
windows-arm64:
name: Windows ARM64 stable portable package
if: ${{ !inputs.windows_only }}
needs: prepare
runs-on: windows-11-arm
timeout-minutes: 120
env:
PACKAGE_VERSION: ${{ needs.prepare.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Require native Windows ARM64
shell: pwsh
run: |
if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'Arm64') {
throw 'ARM64 packaging requires an ARM64 host.'
}
$compiler = rustc -vV
if ($LASTEXITCODE -ne 0 -or $compiler -notcontains 'host: aarch64-pc-windows-msvc') {
throw 'ARM64 packaging requires a native ARM64 compiler.'
}
- uses: ./.github/actions/rust-cache
id: rust-cache
with:
key: release
target: target/release
- name: Prepare and verify pinned ARM64 console runtime
shell: pwsh
run: |
./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost -Architecture arm64
./scripts/tests/windows-runtime.tests.ps1 -Architecture arm64
./scripts/tests/windows-package-architecture.tests.ps1
- name: Build native ARM64 application and hook
shell: pwsh
run: ./scripts/build-windows-product.ps1 -Configuration release -TargetDirectory target
- name: Save compiled workload
if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v4
with:
path: target/release
key: ${{ steps.rust-cache.outputs.cache-key }}
- name: Package native ARM64 ZIP
shell: pwsh
run: ./scripts/package-release.ps1 -Version $env:PACKAGE_VERSION -Architecture arm64 -OutputDirectory dist -TargetDirectory target
- name: Keep Windows PowerShell alive on the ARM64 runner image
shell: pwsh
run: ./scripts/conformance/windows_arm64_module_path.ps1
- name: Run ARM64 Windows conformance
shell: pwsh
env:
NEBULA_RESIZE_TRACE: "1"
NEBULA_BOOT_TRACE: "1"
run: |
python scripts/conformance/windows_standard_user.py scripts/conformance/run.py --app target/release/pebrel.exe --platform windows-aarch64 --output dist/conformance/windows-arm64-report.json
exit $LASTEXITCODE
- name: Upload Windows ARM64 stable asset
if: always()
uses: actions/upload-artifact@v4
with:
compression-level: 0
name: stable-package-windows-arm64
path: dist/Pebrel-v${{ needs.prepare.outputs.version }}-windows-arm64.zip
if-no-files-found: error
retention-days: 14
- name: Upload Windows ARM64 conformance evidence
if: always()
uses: actions/upload-artifact@v4
with:
compression-level: 0
name: stable-evidence-windows-arm64
path: dist/conformance
if-no-files-found: warn
retention-days: 14
aggregate:
name: Verify stable assets and upload complete set
if: ${{ !inputs.windows_only }}
needs: [prepare, native-tests, linux, macos, windows]
needs: [prepare, linux, macos, windows, windows-arm64]
runs-on: ubuntu-22.04
env:
PACKAGE_VERSION: ${{ needs.prepare.outputs.version }}
@@ -573,7 +643,7 @@ jobs:
import os
from pathlib import Path
from scripts.preview_release import validate_evidence
validate_evidence(Path("stable-evidence"), os.environ["SOURCE_COMMIT"])
validate_evidence(Path("stable-evidence"), os.environ["SOURCE_COMMIT"], windows_arm64=True)
PY
python3 scripts/stable_release.py release-dist \
--version "$PACKAGE_VERSION" \
+91 -63
View File
@@ -4,101 +4,129 @@ Every release entry is provided in English and Simplified Chinese.
每个版本条目均同时提供英文和简体中文说明。
## 1.9.0 - 2026-09-21
## 1.9.0 - 2026-09-22
### English
#### Added
- Added live Markdown editing: focus a paragraph or inline expression to edit its source while retaining the document view, selection and undo history.
- Added Kimi Code recognition, session commands and managed notification hooks.
- Added cloud backup controls with saved preferences, connection feedback and access to configuration backups.
- Added a persistent font-ligature setting that applies to open terminal sessions.
- Added a native Windows ARM64 portable ZIP, alongside the Windows x64 installer and portable ZIP. ARM64 builds use a native runner and include matching ARM64 Hook and console runtime binaries.
- Added Kimi Code recognition, native session commands and managed lifecycle hooks, including its icon and terminal activity states.
- Added a dedicated Agents settings page for Claude Code, Codex, OpenCode, Cursor Agent, Kimi Code, Pi, Oh My Pi, GitHub Copilot and Grok. It shows detected CLI paths and actual Hook installation status beside each integration switch. Automatic Hook management is available on Windows.
- Added configurable notification duration: keep the existing defaults, choose 5, 10, 30 or 90 seconds, or keep notifications visible until dismissed. The selected duration takes precedence over an Agent's default banner lifetime.
- Added a persistent terminal font-ligature setting, enabled by default and applied to open sessions without restarting them. Addresses [#220](https://github.com/Kuddev/pebrel/issues/220).
- Added a Sponsors page under Settings → Home → Project and support, introducing Fluxion AI with its sign-up link.
#### Fixed
- Fixed Codex cold restoration using a Hook group identifier instead of the native conversation identity. Saved sessions with an older or unrecognized transcript filename retain their existing valid conversation ID.
- Windows local Codex sessions can recover their exact conversation ID and native path from the running process, including when a Hook identity is missing. Later lifecycle-only events retain the verified identity.
- When Codex reports a missing saved conversation, recovery opens its native chooser, including from CMD. Failed restores also offer an explicit conversation choice in the tab menu.
- Closing with unavailable AI session identities now offers saving known state and exiting; save failures and changed editor drafts still keep the application open.
- Fixed file-editor selections and search matches blending into light and dark backgrounds. Ordinary text/code files now reveal the configured workspace background image, and the caret row follows the active theme.
- Fixed copying the terminal working directory: menu and command-palette actions copy the directory reported by the shell, including guest paths, while normal selection copying retains the original text.
- Fixed AI failure and cancellation events being presented as successful completion; Pi retry-aware outcomes and Claude failure events use the shared notification result handling.
- Fixed WSL hook installation and event delivery using the distribution's own user configuration. Updated command completions include the distribution and user options.
- Fixed inherited Ctrl+C suppression preventing interruption of CMD child commands, while keeping selection copying available.
- Fixed native CMD command activity and multiline/pending-wrap input tracking.
- Fixed application 256-color blocks being overwritten by terminal prompt colors. Explicit custom color overrides remain supported.
- Fixed SSH automatic authentication ignoring the available system agent.
- Fixed long Markdown outline headings overlapping neighboring rows.
- Fixed Markdown caret placement for CJK and emoji, interrupted drag selections, heading layout shifts and code-language pickers extending beyond the window.
- Fixed missing Windows local Codex identities by recovering the native conversation ID and file path from the running process. Later lifecycle-only events retain the verified identity.
- Fixed dead-end Codex restoration when a saved conversation no longer exists: recovery opens the native conversation chooser, including from CMD, and the tab menu offers an explicit conversation choice.
- Fixed the session crash-loop breaker counting a launch as failed while a restored AI conversation was still unconfirmed. Normal exits with a Codex conversation waiting at its startup prompts no longer quarantine the whole workspace after three launches, and the saved conversation target is retained for the next launch.
- Fixed shutdown getting stuck when some AI identities cannot be collected. Users can save the known state and exit; save errors and subsequently changed editor drafts still keep the application open.
- Fixed AI failures and cancellations appearing as successful completion. Claude failure events and Pi retry-aware outcomes now use the shared result handling.
- Fixed notification replacements retaining an old result or expiry deadline. A refreshed pane result uses the selected duration, and an older timer cannot dismiss its replacement.
- Fixed supported AI confirmation actions by checking that the request is still current and completing the required key-down/key-up sequence. Windows notification activation restores the corresponding application window.
- Fixed WSL Hook installation and event delivery to use the distribution's own user configuration. Command completions include the distribution and user options.
- Fixed inherited Ctrl+C suppression preventing interruption of CMD child commands, while preserving selection copying.
- Fixed native CMD command activity detection, multiline input and echoed text at pending-wrap boundaries.
- Fixed application 256-color blocks being overwritten by terminal prompt colors, while retaining explicit custom color overrides.
- Fixed saved SSH hosts ignoring the available system SSH agent in automatic authentication mode.
- Fixed terminal working-directory copying to use the path reported by the shell, including guest paths, while normal selection copying retains the original text.
- Fixed terminal and answer-reader opening of supported local Markdown links, file URIs and relative paths. Paths containing spaces, Chinese text or parentheses resolve against the originating pane's directory; missing paths produce visible feedback. Addresses [#203](https://github.com/Kuddev/pebrel/issues/203).
- Fixed file-editor selections and search matches blending into light and dark backgrounds. Plain-text and code editors reveal the configured workspace background, and the active line follows the theme.
- Fixed long Markdown outline headings overlapping adjacent rows. Full titles remain available through tooltips and copying. Addresses [#184](https://github.com/Kuddev/pebrel/issues/184).
- Fixed interrupted Markdown drags leaving a selection that continues to grow when the pointer moves without a pressed button.
- Fixed code-language menus extending beyond narrow windows and kept their search field and keyboard activation usable.
- Fixed SSH host icons appearing off-center because a font glyph's visible outline differs from its text advance.
- Removed the duplicate title preview shown while dragging a tab.
#### Improved
- Added direct responses for supported AI confirmation notifications, with stale-request checks before sending a response; Windows notification activation restores the corresponding application window.
- Made notification duration configurable and preserved the selected duration when replacing a pane's result.
- Reduced repeated Git/SVN drawer work by reusing repository snapshots and rendering visible rows.
- Improved settings search, SSH connection cards and terminal file-link opening.
- Made cloud-storage configuration compact, bounded provider and credential fields, and aligned file-panel controls.
- Updated command-group navigation, ordering and add actions.
- Added settings for enabling each supported Agent integration while retaining other managed and user-owned hook entries.
- Markdown opens in read-only rendered mode. Text selection, copying, code copying and formula actions remain available; editing requires an explicit switch to source mode.
- Code-block language selection remains available in the reader. It changes syntax highlighting without rewriting the Markdown, survives scrolling away and back, and leaves copied code unchanged.
- Kept source-mode drafts when returning to the reader, while preventing undo/redo shortcuts from editing the document in read-only mode.
- Bounded Markdown presentation work with virtual rows, visible-block image ownership and release of inactive document layouts. These changes reduce repeated work without imposing a machine-specific memory or speed promise.
- Reused repository snapshots and rendered visible Git/SVN drawer rows to reduce repeated work in large repositories.
- Centered the Agents page, enlarged all nine Agent marks using SVG assets, and separated CLI discovery from Hook installation status. Long paths remain accessible through tooltips.
- Improved settings search, SSH connection cards and filters, file-panel control alignment, and command-group navigation, ordering and add actions.
- Preserved other integrations and user-owned configuration when installing or removing a managed Agent Hook.
- Temporarily hid the backup entry from the settings sidebar and search; cloud-backup controls are not exposed in this release.
Windows packages include an installer and portable ZIP. Linux and macOS packages retain their Preview designation. This candidate is intended for installation testing; visual acceptance and live cold restoration with every external CLI remain separate from automated checks.
Windows x64 includes an installer and portable ZIP; Windows ARM64 includes a native portable ZIP. The installer and in-app automatic installation remain x64-only. Linux x64 packages and macOS Apple Silicon/Intel DMGs retain their Preview designation. macOS requires version 14 or later. Molecular structure rendering remains disabled.
### 中文
#### 新增
- 新增 Markdown 即时编辑:聚焦段落或行内表达式即可编辑对应源码,同时保留文档视图、选区和撤销历史。
- 新增 Kimi Code 识别、会话命令和受管理的通知 Hook。
- 新增云端备份入口,支持保存偏好、连接反馈和访问配置备份。
- 新增可持久保存的字体连字设置,并可应用于已打开的终端会话。
- 新增:Windows ARM64 原生便携 ZIP,与 Windows x64 安装器及便携 ZIP 一同提供。ARM64 使用原生运行器构建,并配套 ARM64 Hook 辅助程序与控制台运行时。
- 新增:Kimi Code 识别、原生会话命令和受管理的生命周期 Hook,包含品牌图标及终端活动状态。
- 新增:独立 Agents 设置页,覆盖 Claude Code、Codex、OpenCode、Cursor Agent、Kimi Code、Pi、Oh My Pi、GitHub Copilot 和 Grok。每项显示检测到的 CLI 路径,并在开关旁显示实际 Hook 安装状态;自动 Hook 管理适用于 Windows。
- 新增:通知停留时长设置:保持既有默认值,或选择 5、10、30、90 秒以及手动关闭。用户选择的时长优先于 Agent 自带的横幅默认时长。
- 新增:可持久保存的终端字体连字开关,默认开启,并可应用于已打开的会话,无需重启终端。对应 [#220](https://github.com/Kuddev/pebrel/issues/220)。
- 新增:设置首页「项目与支持」中的赞助商页面,介绍赞助商 Fluxion AI 并提供注册链接。
#### 修复
- 修复 Codex 冷恢复误用 Hook 分组标识而非原生对话身份的问题。旧快照中的会话文件名无法识别时,保留原有有效对话 ID。
- Windows 本地 Codex 会话可从正在运行的进程找回准确的对话 ID 和原生文件路径,补足 Hook 身份缺失的情况;后续仅包含生命周期信息的事件会保留已核验身份。
- Codex 报告保存的对话不存在时,包括 CMD 在内的恢复流程可打开原生会话选择器;恢复失败后,也可从标签页菜单主动选择对话。
- AI 会话身份暂时无法取得时,关闭操作提供保存已知状态并退出的选择;保存失败或编辑器草稿发生变化时仍保持应用打开。
- 修复文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码文件可透出已配置的工作区背景图,光标所在行跟随当前主题。
- 修复终端工作目录复制:菜单和命令面板复制 Shell 上报的目录,包括访客环境路径;普通选区复制保留原始文字。
- 修复 AI 失败和取消事件被显示为成功完成的问题;Pi 重试后的结果及 Claude 失败事件统一进入通知结果处理。
- 修复 WSL Hook 安装与事件传递,使用发行版自身的用户配置;命令补全同步包含发行版和用户选项。
- 修复继承的 Ctrl+C 忽略状态导致 CMD 子命令无法中断的问题,同时保留选区复制。
- 修复原生 CMD 命令活动、多行输入和等待折行边界的输入跟踪。
- 修复应用的 256 色块被终端提示符颜色覆盖的问题,保留明确配置的自定义颜色覆盖。
- 修复 SSH 自动认证忽略可用系统代理的问题。
- 修复 Markdown 目录长标题与相邻行重叠的问题。
- 修复 Markdown 中文与 emoji 的光标落点、中断拖动后残留选区、标题编辑时布局跳动以及代码语言菜单超出窗口的问题。
- 修复:Codex 冷恢复误用 Hook 分组标识而非原生对话身份的问题。旧快照中的会话文件名无法识别时,保留原有有效对话 ID。
- 修复:Windows 本地 Codex 身份缺失的问题,可从运行中的进程找回原生对话 ID 和文件路径;后续仅包含生命周期信息的事件会保留已核验身份。
- 修复:保存的 Codex 对话不存在时恢复流程无法继续的问题:包括 CMD 在内,可打开原生会话选择器,标签菜单也提供主动选择对话的入口。
- 修复:已恢复的 AI 对话尚未确认时,本次启动被会话断路器误计为失败的问题。Codex 对话停在启动提示上、正常退出三次后,不再把整个工作区隔离到 `session.crashed.json`;已保存的对话目标保留至下次启动。
- 修复:部分 AI 身份无法取得时关闭流程卡住的问题。用户可以保存已知状态并退出;保存失败或编辑器草稿随后发生变化时仍保持应用打开。
- 修复:AI 失败和取消事件被显示为成功完成的问题,Claude 失败事件及 Pi 重试后的结果统一进入通知结果处理。
- 修复:通知替换后残留旧结果或旧到期时间的问题。新的窗格结果遵循选定时长,旧计时器不能关闭替换后的通知。
- 修复:受支持 AI 确认动作:发送前检查请求仍然有效,并完成必要的按下、释放按键序列;Windows 通知激活时恢复对应应用窗口。
- 修复:WSL Hook 安装与事件传递,使用发行版自身的用户配置;命令补全同步包含发行版和用户选项。
- 修复:继承的 Ctrl+C 忽略状态导致 CMD 子命令无法中断的问题,同时保留选区复制。
- 修复:原生 CMD 命令活动检测、多行输入以及等待折行边界的回显文本跟踪。
- 修复:应用的 256 色块被终端提示符颜色覆盖的问题,保留明确配置的自定义颜色覆盖。
- 修复:已保存 SSH 主机在自动认证时忽略可用系统 SSH 代理的问题。
- 修复:终端工作目录复制,改为复制 Shell 上报的路径,包括访客环境路径;普通选区复制保留原始文字。
- 修复:终端和回答阅读页打开受支持的本地 Markdown 链接、文件 URI 及相对路径的行为。含空格、中文或括号的路径按原窗格目录解析,路径缺失时提供可见反馈。对应 [#203](https://github.com/Kuddev/pebrel/issues/203)。
- 修复:文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码编辑器可透出已配置的工作区背景图,光标所在行跟随主题。
- 修复:Markdown 目录长标题压住相邻行的问题,完整标题仍可通过悬停提示和复制取得。对应 [#184](https://github.com/Kuddev/pebrel/issues/184)。
- 修复:Markdown 拖选被中断后,未按住鼠标移动仍继续扩大选区的问题。
- 修复:代码语言菜单在窄窗口中越界的问题,并保持搜索框和键盘操作可用。
- 修复:SSH 主机图标因字体可见轮廓与排版宽度不同而偏离中心的问题。
- 修复:移除拖动标签时重复出现的标题预览。
#### 改进
- 支持从部分 AI 确认通知直接回答,发送前检查请求是否过期;Windows 通知激活时恢复对应的应用窗口。
- 支持配置通知停留时长,并在替换窗格结果时保留选定时长。
- 复用仓库快照、仅渲染可见行,减少 Git/SVN 抽屉的重复工作。
- 改进设置搜索、SSH 连接卡片和终端文件链接打开。
- 收紧云存储配置布局,限制服务选择器和凭据输入宽度,并统一文件面板控件尺寸。
- 更新命令分组导航、排序和新增操作的布局。
- 新增各受支持 Agent 的集成开关,安装与移除时保留其他受管理及用户已有 Hook 项。
- 改进:Markdown 默认以只读方式渲染,保留文字选择、复制、代码复制及公式操作;编辑需要显式切换到源码模式。
- 改进:阅读模式仍可选择代码块语言,只改变语法高亮,不改写 Markdown;滚动离开再返回后保留选择,复制得到的代码内容不变。
- 改进:从源码模式返回阅读页时保留草稿,同时阻止只读模式下的撤销、重做快捷键修改文档。
- 改进:Markdown 使用虚拟列表、可见代码块和图片资源管理,并释放非活动文档的布局缓存,减少重复工作;不把单机结果写成固定的内存或速度承诺。
- 改进:Git/SVN 抽屉复用仓库快照,仅渲染可见条目,减少大型仓库中的重复工作。
- 改进:Agents 页面整体居中,九种 Agent 图标使用放大的 SVG 资源;CLI 是否存在与 Hook 是否安装分别展示,长路径可通过悬停提示查看。
- 改进:设置搜索、SSH 连接卡片和筛选、文件面板控件对齐,以及命令分组的导航、排序和新增操作。
- 改进:安装或移除受管理的 Agent Hook 时,保留其他集成及用户已有配置。
- 改进:暂时隐藏设置侧栏和搜索中的备份入口,本版不开放云备份控件。
Windows 提供安装器和 ZIP 便携包,Linux 和 macOS 包继续标记为 Preview。本候选用于安装测试;视觉验收和所有外部 CLI 的实际关窗恢复仍需单独验证。
Windows x64 提供安装器和 ZIP 便携包,Windows ARM64 提供原生便携 ZIP;安装器和应用内自动安装仍仅支持 x64。Linux x64 包及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。macOS 最低版本为 14。分子结构渲染仍处于禁用状态。
### Contributors
<a href="https://github.com/Traveritas"><img src="https://github.com/Traveritas.png?size=96" width="64" height="64" alt="@Traveritas avatar"></a><a href="https://github.com/galact-byte"><img src="https://github.com/galact-byte.png?size=96" width="64" height="64" alt="@galact-byte avatar"></a><a href="https://github.com/Kuddev"><img src="https://github.com/Kuddev.png?size=96" width="64" height="64" alt="@Kuddev avatar"></a>
<a href="https://github.com/Traveritas"><img src="https://github.com/Traveritas.png?size=96" width="64" height="64" alt="@Traveritas avatar"></a><a href="https://github.com/galact-byte"><img src="https://github.com/galact-byte.png?size=96" width="64" height="64" alt="@galact-byte avatar"></a><a href="https://github.com/821869798"><img src="https://github.com/821869798.png?size=96" width="64" height="64" alt="@821869798 avatar"></a><a href="https://github.com/tataraDM"><img src="https://github.com/tataraDM.png?size=96" width="64" height="64" alt="@tataraDM avatar"></a><a href="https://github.com/Aschenbath"><img src="https://github.com/Aschenbath.png?size=96" width="64" height="64" alt="@Aschenbath avatar"></a><a href="https://github.com/Kuddev"><img src="https://github.com/Kuddev.png?size=96" width="64" height="64" alt="@Kuddev avatar"></a>
- **[@Traveritas](https://github.com/Traveritas)** — Kimi Code integration. / Kimi Code 集成。([#197](https://github.com/Kuddev/pebrel/pull/197))
- **[@galact-byte](https://github.com/galact-byte)** — CMD interrupts and activity, application colors and Pi notification outcomes. / CMD 中断与活动、应用配色及 Pi 通知结果。([#206](https://github.com/Kuddev/pebrel/pull/206), [#207](https://github.com/Kuddev/pebrel/pull/207), [#208](https://github.com/Kuddev/pebrel/pull/208), [#215](https://github.com/Kuddev/pebrel/pull/215), [#216](https://github.com/Kuddev/pebrel/pull/216))
- **[@Kuddev](https://github.com/Kuddev)** — Desktop integration, notifications, restoration and editor fixes. / 桌面集成、通知、恢复及编辑器修复。([#221](https://github.com/Kuddev/pebrel/pull/221), [#225](https://github.com/Kuddev/pebrel/pull/225), [#227](https://github.com/Kuddev/pebrel/pull/227), [#231](https://github.com/Kuddev/pebrel/pull/231), [#232](https://github.com/Kuddev/pebrel/pull/232))
- **[@Traveritas](https://github.com/Traveritas)** — Kimi Code hooks, recognition and branding. / Kimi Code Hook、识别和品牌图标。([#197](https://github.com/Kuddev/pebrel/pull/197))
- **[@galact-byte](https://github.com/galact-byte)** — CMD interrupts and activity, terminal application colors and Pi notification outcomes. / CMD 中断与活动、终端应用配色及 Pi 通知结果。([#206](https://github.com/Kuddev/pebrel/pull/206), [#207](https://github.com/Kuddev/pebrel/pull/207), [#208](https://github.com/Kuddev/pebrel/pull/208), [#215](https://github.com/Kuddev/pebrel/pull/215), [#216](https://github.com/Kuddev/pebrel/pull/216))
- **[@821869798](https://github.com/821869798)** — Markdown links and local file opening from terminals and the answer reader. / 终端及回答阅读页的 Markdown 链接和本地文件打开。([#204](https://github.com/Kuddev/pebrel/pull/204))
- **[@tataraDM](https://github.com/tataraDM)** — Readable single-line Markdown outline headings. / 修复 Markdown 目录长标题挤压。([#191](https://github.com/Kuddev/pebrel/pull/191))
- **[@Aschenbath](https://github.com/Aschenbath)** — Configurable notification duration and persistent notifications. / 可配置的通知停留时长和常驻通知。([#131](https://github.com/Kuddev/pebrel/pull/131))
- **[@Kuddev](https://github.com/Kuddev)** — Desktop integration, actionable notifications, session restoration, Markdown reader, Agents UI, review and release integration. / 桌面整合、可操作通知、会话恢复、Markdown 阅读、Agents 界面、审核及发布整合。([#210](https://github.com/Kuddev/pebrel/pull/210), [#217](https://github.com/Kuddev/pebrel/pull/217), [#221](https://github.com/Kuddev/pebrel/pull/221), [#222](https://github.com/Kuddev/pebrel/pull/222), [#225](https://github.com/Kuddev/pebrel/pull/225), [#227](https://github.com/Kuddev/pebrel/pull/227), [#231](https://github.com/Kuddev/pebrel/pull/231), [#232](https://github.com/Kuddev/pebrel/pull/232))
---
**SHA256**
- `Pebrel-v1.9.0-linux-x64-preview.AppImage`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-linux-x64-preview.deb`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-macos-arm64-preview.dmg`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-macos-x64-preview.dmg`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-windows-x64.zip`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-windows-x64-setup.exe`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-linux-x64-preview.AppImage`: `4da5ea590a82ab4f89dca14df1399e7c2cd27fa4fe308da6f0e95b8aa9ae7d22`
- `Pebrel-v1.9.0-linux-x64-preview.deb`: `bb6fff964942b750a794f2ea148e851719430dfffd9bb086a13e2f1e67d87722`
- `Pebrel-v1.9.0-linux-x64-preview.tar.gz`: `6526ed5f2f883bb5ca9600d2685c929056fbae9f2450259f34d65093a18deda6`
- `Pebrel-v1.9.0-macos-arm64-preview.dmg`: `daf2c6c381fbdf84c33c132e9c6c247e9f9dc6e86292cc5be855e635e6cfa6f3`
- `Pebrel-v1.9.0-macos-x64-preview.dmg`: `9f3f1465be9e1f3489be09ccf336d5bdc0a6ae40185062f677a6e857d47e1fe0`
- `Pebrel-v1.9.0-windows-x64.zip`: `1c219946450c7d93993394fd6d40c6e234d77547c9b4a98106c0d33ada9c27f9`
- `Pebrel-v1.9.0-windows-x64-setup.exe`: `52cefcdae9cd98035bcdd45944c020fc9acf9c2c497147ba337ed15069a07fe6`
- `Pebrel-v1.9.0-windows-arm64.zip`: `e2d062089afb19ddb9e3d078cc7f460a1d67cb36d95400cebab837f9fabda092`
## 1.8.2 - 2026-09-17
+17
View File
@@ -27,6 +27,7 @@
<img src="https://img.shields.io/github/stars/Kuddev/pebrel?style=flat-square&color=ffd33d&logo=github" alt="Stars" />
<img src="https://img.shields.io/github/forks/Kuddev/pebrel?style=flat-square&color=8957e5&logo=github" alt="Forks" />
<img src="https://img.shields.io/github/last-commit/Kuddev/pebrel?style=flat-square&color=3fb950" alt="Last commit" />
<a href="https://discord.gg/VFn4rcxmhn"><img src="https://img.shields.io/badge/Discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://linux.do"><img src="https://img.shields.io/badge/%E5%8F%8B%E9%93%BE-linux.do-ffb003?style=flat-square&logo=discourse&logoColor=white" alt="linux.do" /></a>
</p>
@@ -48,6 +49,20 @@
<img src="docs/screenshots/split-ai-workflows.png" alt="OpenCode, Claude Code, and Codex in split panes" width="1040" />
</p>
## 💖 Sponsors
<table>
<tr>
<td align="center" width="240">
<a href="https://fluxionai.space/register?source=github&campaign=pebrel&promo=pebrel"><img src="extra/logo/sponsor_fluxionai.png" alt="Fluxion AI" width="200" /></a><br />
<a href="https://fluxionai.space/register?source=github&campaign=pebrel&promo=pebrel"><strong>Fluxion AI</strong></a>
</td>
<td>
Thanks to Fluxion AI for sponsoring this project! Fluxion AI is one entry point for accessing and managing the world's leading AI models. It serves individual developers, technical teams and enterprises with a unified API; dynamic multi-route scheduling improves availability, and model performance, response time and cost stay transparent. Depending on the model and route, API calls can cost 40%–98% less than official or benchmark prices. <a href="https://fluxionai.space/register?source=github&campaign=pebrel&promo=pebrel">Sign up via this link</a> to receive $3 in API credit.
</td>
</tr>
</table>
## One Workspace
Pebrel (formerly Nebula) brings local shells, remote hosts, files, and AI command-line
@@ -176,6 +191,8 @@ through the explicit `legacy-shell` feature.
Email: [fickleheartedkeys@163.com](mailto:fickleheartedkeys@163.com)
Discord: [discord.gg/VFn4rcxmhn](https://discord.gg/VFn4rcxmhn)
## Acknowledgements
Pebrel builds on [Alacritty](https://github.com/alacritty/alacritty),
+17
View File
@@ -27,6 +27,7 @@
<img src="https://img.shields.io/github/stars/Kuddev/pebrel?style=flat-square&color=ffd33d&logo=github" alt="Stars" />
<img src="https://img.shields.io/github/forks/Kuddev/pebrel?style=flat-square&color=8957e5&logo=github" alt="Forks" />
<img src="https://img.shields.io/github/last-commit/Kuddev/pebrel?style=flat-square&color=3fb950" alt="最近提交" />
<a href="https://discord.gg/VFn4rcxmhn"><img src="https://img.shields.io/badge/Discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://linux.do"><img src="https://img.shields.io/badge/%E5%8F%8B%E9%93%BE-linux.do-ffb003?style=flat-square&logo=discourse&logoColor=white" alt="linux.do" /></a>
</p>
@@ -48,6 +49,20 @@
<img src="docs/screenshots/split-ai-workflows.png" alt="OpenCode、Claude Code 与 Codex 分屏工作流" width="1040" />
</p>
## 💖 赞助商
<table>
<tr>
<td align="center" width="240">
<a href="https://fluxionai.space/register?source=github&campaign=pebrel&promo=pebrel"><img src="extra/logo/sponsor_fluxionai.png" alt="Fluxion AI" width="200" /></a><br />
<a href="https://fluxionai.space/register?source=github&campaign=pebrel&promo=pebrel"><strong>Fluxion AI</strong></a>
</td>
<td>
感谢 Fluxion AI 赞助本项目!Fluxion AI 是一个入口,接入并管理全球主流 AI 模型:面向个人开发者、技术团队与企业,通过统一 API 接入并管理全球主流 AI 模型;通过多线路动态调度提升可用性,模型表现、响应时间与费用透明可查。根据不同模型与线路,API 调用成本较官方或基准价格可降低 40%—98%。<a href="https://fluxionai.space/register?source=github&campaign=pebrel&promo=pebrel">立即访问并注册</a>,即可获得 $3 API 额度。
</td>
</tr>
</table>
## 一个工作区
Pebrel(原名 Nebula)把本地 Shell、远程主机、文件和 AI 命令行工具放进同一个原生桌面工作区。
@@ -158,6 +173,8 @@ cargo build --release --locked -p nebula --bin pebrel --features gpui-shell
邮箱:[fickleheartedkeys@163.com](mailto:fickleheartedkeys@163.com)
Discord:[discord.gg/VFn4rcxmhn](https://discord.gg/VFn4rcxmhn)
## 致谢
Pebrel 基于 [Alacritty](https://github.com/alacritty/alacritty)、
@@ -0,0 +1,65 @@
# Native Windows ARM64 portable release
## Status
Implemented for the 1.9.0 release candidate, following the maintainer's request
to include Windows ARM builds in release CI. Public release remains pending.
## Context
The native test matrix already includes Windows ARM64, while stable release
packaging previously produced only Windows x64 binaries. Passing ARM64 tests
did not give ARM users a downloadable native application.
## Evidence
- `linux-lua.yml` runs tests on `windows-11-arm` and checks the compiler host.
- `package-release.ps1` previously fixed the archive suffix to `windows-x64`.
- The existing console-runtime preparation script has pinned ARM64 payloads.
- The installer and automatic installer selection currently target Windows x64.
## Decision
Add a native ARM64 portable ZIP to the stable asset manifest from 1.9.0 onward.
Build the application and Hook helper on the ARM64 runner with the shared
product build script. Package the corresponding pinned ARM64 console runtime.
Verify PE machine headers for all four executable payloads before assigning
the requested architecture to the ZIP filename.
The full release aggregate requires both the existing native ARM64 test job and
an ARM64 conformance report from the same source commit. Historical release
manifests and the separate Preview workflow retain their current contracts.
Keep the Windows installer and automatic installation on x64 for this release.
The ARM64 artifact is explicitly described as a portable ZIP; an ARM64 installer
requires its own native installation, migration and update-contract validation.
## Rejected alternatives
- Renaming an x64 archive would not provide native ARM64 execution.
- Cross-compilation alone would not exercise native startup and terminal behavior.
- Treating the native test job as packaging evidence would leave downloadable
payloads and architecture mismatches unverified.
## Consequences
The 1.9.0 stable manifest contains eight packages plus SHA256SUMS. The additional
release job owns its runner, architecture-specific build cache and evidence.
No installer compatibility alias or historical asset is removed.
## Validation
- Package-header tests accept both supported machine types and reject a wrong
label, mismatched Hook helper and truncated executable.
- Release-manifest tests require the ARM64 ZIP for 1.9.0 and retain old manifests.
- Evidence tests reject missing or mislabeled ARM64 reports when ARM64 is required.
- Native release CI is required before attaching candidate assets to the draft.
## Supersedes
None.
## Revisit when
A native ARM64 installer and its update/migration behavior have been implemented
and verified, or the supported Windows release architectures change.
+90 -62
View File
@@ -4,92 +4,120 @@
### Added
- Added live Markdown editing: focus a paragraph or inline expression to edit its source while retaining the document view, selection and undo history.
- Added Kimi Code recognition, session commands and managed notification hooks.
- Added cloud backup controls with saved preferences, connection feedback and access to configuration backups.
- Added a persistent font-ligature setting that applies to open terminal sessions.
- Added a native Windows ARM64 portable ZIP, alongside the Windows x64 installer and portable ZIP. ARM64 builds use a native runner and include matching ARM64 Hook and console runtime binaries.
- Added Kimi Code recognition, native session commands and managed lifecycle hooks, including its icon and terminal activity states.
- Added a dedicated Agents settings page for Claude Code, Codex, OpenCode, Cursor Agent, Kimi Code, Pi, Oh My Pi, GitHub Copilot and Grok. It shows detected CLI paths and actual Hook installation status beside each integration switch. Automatic Hook management is available on Windows.
- Added configurable notification duration: keep the existing defaults, choose 5, 10, 30 or 90 seconds, or keep notifications visible until dismissed. The selected duration takes precedence over an Agent's default banner lifetime.
- Added a persistent terminal font-ligature setting, enabled by default and applied to open sessions without restarting them. Addresses [#220](https://github.com/Kuddev/pebrel/issues/220).
- Added a Sponsors page under Settings → Home → Project and support, introducing Fluxion AI with its sign-up link.
### Fixed
- Fixed Codex cold restoration using a Hook group identifier instead of the native conversation identity. Saved sessions with an older or unrecognized transcript filename retain their existing valid conversation ID.
- Windows local Codex sessions can recover their exact conversation ID and native path from the running process, including when a Hook identity is missing. Later lifecycle-only events retain the verified identity.
- When Codex reports a missing saved conversation, recovery opens its native chooser, including from CMD. Failed restores also offer an explicit conversation choice in the tab menu.
- Closing with unavailable AI session identities now offers saving known state and exiting; save failures and changed editor drafts still keep the application open.
- Fixed file-editor selections and search matches blending into light and dark backgrounds. Ordinary text/code files now reveal the configured workspace background image, and the caret row follows the active theme.
- Fixed copying the terminal working directory: menu and command-palette actions copy the directory reported by the shell, including guest paths, while normal selection copying retains the original text.
- Fixed AI failure and cancellation events being presented as successful completion; Pi retry-aware outcomes and Claude failure events use the shared notification result handling.
- Fixed WSL hook installation and event delivery using the distribution's own user configuration. Updated command completions include the distribution and user options.
- Fixed inherited Ctrl+C suppression preventing interruption of CMD child commands, while keeping selection copying available.
- Fixed native CMD command activity and multiline/pending-wrap input tracking.
- Fixed application 256-color blocks being overwritten by terminal prompt colors. Explicit custom color overrides remain supported.
- Fixed SSH automatic authentication ignoring the available system agent.
- Fixed long Markdown outline headings overlapping neighboring rows.
- Fixed Markdown caret placement for CJK and emoji, interrupted drag selections, heading layout shifts and code-language pickers extending beyond the window.
- Fixed missing Windows local Codex identities by recovering the native conversation ID and file path from the running process. Later lifecycle-only events retain the verified identity.
- Fixed dead-end Codex restoration when a saved conversation no longer exists: recovery opens the native conversation chooser, including from CMD, and the tab menu offers an explicit conversation choice.
- Fixed the session crash-loop breaker counting a launch as failed while a restored AI conversation was still unconfirmed. Normal exits with a Codex conversation waiting at its startup prompts no longer quarantine the whole workspace after three launches, and the saved conversation target is retained for the next launch.
- Fixed shutdown getting stuck when some AI identities cannot be collected. Users can save the known state and exit; save errors and subsequently changed editor drafts still keep the application open.
- Fixed AI failures and cancellations appearing as successful completion. Claude failure events and Pi retry-aware outcomes now use the shared result handling.
- Fixed notification replacements retaining an old result or expiry deadline. A refreshed pane result uses the selected duration, and an older timer cannot dismiss its replacement.
- Fixed supported AI confirmation actions by checking that the request is still current and completing the required key-down/key-up sequence. Windows notification activation restores the corresponding application window.
- Fixed WSL Hook installation and event delivery to use the distribution's own user configuration. Command completions include the distribution and user options.
- Fixed inherited Ctrl+C suppression preventing interruption of CMD child commands, while preserving selection copying.
- Fixed native CMD command activity detection, multiline input and echoed text at pending-wrap boundaries.
- Fixed application 256-color blocks being overwritten by terminal prompt colors, while retaining explicit custom color overrides.
- Fixed saved SSH hosts ignoring the available system SSH agent in automatic authentication mode.
- Fixed terminal working-directory copying to use the path reported by the shell, including guest paths, while normal selection copying retains the original text.
- Fixed terminal and answer-reader opening of supported local Markdown links, file URIs and relative paths. Paths containing spaces, Chinese text or parentheses resolve against the originating pane's directory; missing paths produce visible feedback. Addresses [#203](https://github.com/Kuddev/pebrel/issues/203).
- Fixed file-editor selections and search matches blending into light and dark backgrounds. Plain-text and code editors reveal the configured workspace background, and the active line follows the theme.
- Fixed long Markdown outline headings overlapping adjacent rows. Full titles remain available through tooltips and copying. Addresses [#184](https://github.com/Kuddev/pebrel/issues/184).
- Fixed interrupted Markdown drags leaving a selection that continues to grow when the pointer moves without a pressed button.
- Fixed code-language menus extending beyond narrow windows and kept their search field and keyboard activation usable.
- Fixed SSH host icons appearing off-center because a font glyph's visible outline differs from its text advance.
- Removed the duplicate title preview shown while dragging a tab.
### Improved
- Added direct responses for supported AI confirmation notifications, with stale-request checks before sending a response; Windows notification activation restores the corresponding application window.
- Made notification duration configurable and preserved the selected duration when replacing a pane's result.
- Reduced repeated Git/SVN drawer work by reusing repository snapshots and rendering visible rows.
- Improved settings search, SSH connection cards and terminal file-link opening.
- Made cloud-storage configuration compact, bounded provider and credential fields, and aligned file-panel controls.
- Updated command-group navigation, ordering and add actions.
- Added settings for enabling each supported Agent integration while retaining other managed and user-owned hook entries.
- Markdown opens in read-only rendered mode. Text selection, copying, code copying and formula actions remain available; editing requires an explicit switch to source mode.
- Code-block language selection remains available in the reader. It changes syntax highlighting without rewriting the Markdown, survives scrolling away and back, and leaves copied code unchanged.
- Kept source-mode drafts when returning to the reader, while preventing undo/redo shortcuts from editing the document in read-only mode.
- Bounded Markdown presentation work with virtual rows, visible-block image ownership and release of inactive document layouts. These changes reduce repeated work without imposing a machine-specific memory or speed promise.
- Reused repository snapshots and rendered visible Git/SVN drawer rows to reduce repeated work in large repositories.
- Centered the Agents page, enlarged all nine Agent marks using SVG assets, and separated CLI discovery from Hook installation status. Long paths remain accessible through tooltips.
- Improved settings search, SSH connection cards and filters, file-panel control alignment, and command-group navigation, ordering and add actions.
- Preserved other integrations and user-owned configuration when installing or removing a managed Agent Hook.
- Temporarily hid the backup entry from the settings sidebar and search; cloud-backup controls are not exposed in this release.
Windows packages include an installer and portable ZIP. Linux and macOS packages retain their Preview designation. This candidate is intended for installation testing; visual acceptance and live cold restoration with every external CLI remain separate from automated checks.
Windows x64 includes an installer and portable ZIP; Windows ARM64 includes a native portable ZIP. The installer and in-app automatic installation remain x64-only. Linux x64 packages and macOS Apple Silicon/Intel DMGs retain their Preview designation. macOS requires version 14 or later. Molecular structure rendering remains disabled.
## 中文
### 新增
- 新增 Markdown 即时编辑:聚焦段落或行内表达式即可编辑对应源码,同时保留文档视图、选区和撤销历史。
- 新增 Kimi Code 识别、会话命令和受管理的通知 Hook。
- 新增云端备份入口,支持保存偏好、连接反馈和访问配置备份。
- 新增可持久保存的字体连字设置,并可应用于已打开的终端会话。
- 新增:Windows ARM64 原生便携 ZIP,与 Windows x64 安装器及便携 ZIP 一同提供。ARM64 使用原生运行器构建,并配套 ARM64 Hook 辅助程序与控制台运行时。
- 新增:Kimi Code 识别、原生会话命令和受管理的生命周期 Hook,包含品牌图标及终端活动状态。
- 新增:独立 Agents 设置页,覆盖 Claude Code、Codex、OpenCode、Cursor Agent、Kimi Code、Pi、Oh My Pi、GitHub Copilot 和 Grok。每项显示检测到的 CLI 路径,并在开关旁显示实际 Hook 安装状态;自动 Hook 管理适用于 Windows。
- 新增:通知停留时长设置:保持既有默认值,或选择 5、10、30、90 秒以及手动关闭。用户选择的时长优先于 Agent 自带的横幅默认时长。
- 新增:可持久保存的终端字体连字开关,默认开启,并可应用于已打开的会话,无需重启终端。对应 [#220](https://github.com/Kuddev/pebrel/issues/220)。
- 新增:设置首页「项目与支持」中的赞助商页面,介绍赞助商 Fluxion AI 并提供注册链接。
### 修复
- 修复 Codex 冷恢复误用 Hook 分组标识而非原生对话身份的问题。旧快照中的会话文件名无法识别时,保留原有有效对话 ID。
- Windows 本地 Codex 会话可从正在运行的进程找回准确的对话 ID 和原生文件路径,补足 Hook 身份缺失的情况;后续仅包含生命周期信息的事件会保留已核验身份。
- Codex 报告保存的对话不存在时,包括 CMD 在内的恢复流程可打开原生会话选择器;恢复失败后,也可从标签页菜单主动选择对话。
- AI 会话身份暂时无法取得时,关闭操作提供保存已知状态并退出的选择;保存失败或编辑器草稿发生变化时仍保持应用打开。
- 修复文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码文件可透出已配置的工作区背景图,光标所在行跟随当前主题。
- 修复终端工作目录复制:菜单和命令面板复制 Shell 上报的目录,包括访客环境路径;普通选区复制保留原始文字。
- 修复 AI 失败和取消事件被显示为成功完成的问题;Pi 重试后的结果及 Claude 失败事件统一进入通知结果处理。
- 修复 WSL Hook 安装与事件传递,使用发行版自身的用户配置;命令补全同步包含发行版和用户选项。
- 修复继承的 Ctrl+C 忽略状态导致 CMD 子命令无法中断的问题,同时保留选区复制。
- 修复原生 CMD 命令活动、多行输入和等待折行边界的输入跟踪。
- 修复应用的 256 色块被终端提示符颜色覆盖的问题,保留明确配置的自定义颜色覆盖。
- 修复 SSH 自动认证忽略可用系统代理的问题。
- 修复 Markdown 目录长标题与相邻行重叠的问题。
- 修复 Markdown 中文与 emoji 的光标落点、中断拖动后残留选区、标题编辑时布局跳动以及代码语言菜单超出窗口的问题。
- 修复:Codex 冷恢复误用 Hook 分组标识而非原生对话身份的问题。旧快照中的会话文件名无法识别时,保留原有有效对话 ID。
- 修复:Windows 本地 Codex 身份缺失的问题,可从运行中的进程找回原生对话 ID 和文件路径;后续仅包含生命周期信息的事件会保留已核验身份。
- 修复:保存的 Codex 对话不存在时恢复流程无法继续的问题:包括 CMD 在内,可打开原生会话选择器,标签菜单也提供主动选择对话的入口。
- 修复:已恢复的 AI 对话尚未确认时,本次启动被会话断路器误计为失败的问题。Codex 对话停在启动提示上、正常退出三次后,不再把整个工作区隔离到 `session.crashed.json`;已保存的对话目标保留至下次启动。
- 修复:部分 AI 身份无法取得时关闭流程卡住的问题。用户可以保存已知状态并退出;保存失败或编辑器草稿随后发生变化时仍保持应用打开。
- 修复:AI 失败和取消事件被显示为成功完成的问题,Claude 失败事件及 Pi 重试后的结果统一进入通知结果处理。
- 修复:通知替换后残留旧结果或旧到期时间的问题。新的窗格结果遵循选定时长,旧计时器不能关闭替换后的通知。
- 修复:受支持 AI 确认动作:发送前检查请求仍然有效,并完成必要的按下、释放按键序列;Windows 通知激活时恢复对应应用窗口。
- 修复:WSL Hook 安装与事件传递,使用发行版自身的用户配置;命令补全同步包含发行版和用户选项。
- 修复:继承的 Ctrl+C 忽略状态导致 CMD 子命令无法中断的问题,同时保留选区复制。
- 修复:原生 CMD 命令活动检测、多行输入以及等待折行边界的回显文本跟踪。
- 修复:应用的 256 色块被终端提示符颜色覆盖的问题,保留明确配置的自定义颜色覆盖。
- 修复:已保存 SSH 主机在自动认证时忽略可用系统 SSH 代理的问题。
- 修复:终端工作目录复制,改为复制 Shell 上报的路径,包括访客环境路径;普通选区复制保留原始文字。
- 修复:终端和回答阅读页打开受支持的本地 Markdown 链接、文件 URI 及相对路径的行为。含空格、中文或括号的路径按原窗格目录解析,路径缺失时提供可见反馈。对应 [#203](https://github.com/Kuddev/pebrel/issues/203)。
- 修复:文件编辑器选区与查找命中融入浅色、深色背景的问题;普通文本和代码编辑器可透出已配置的工作区背景图,光标所在行跟随主题。
- 修复:Markdown 目录长标题压住相邻行的问题,完整标题仍可通过悬停提示和复制取得。对应 [#184](https://github.com/Kuddev/pebrel/issues/184)。
- 修复:Markdown 拖选被中断后,未按住鼠标移动仍继续扩大选区的问题。
- 修复:代码语言菜单在窄窗口中越界的问题,并保持搜索框和键盘操作可用。
- 修复:SSH 主机图标因字体可见轮廓与排版宽度不同而偏离中心的问题。
- 修复:移除拖动标签时重复出现的标题预览。
### 改进
- 支持从部分 AI 确认通知直接回答,发送前检查请求是否过期;Windows 通知激活时恢复对应的应用窗口。
- 支持配置通知停留时长,并在替换窗格结果时保留选定时长。
- 复用仓库快照、仅渲染可见行,减少 Git/SVN 抽屉的重复工作。
- 改进设置搜索、SSH 连接卡片和终端文件链接打开。
- 收紧云存储配置布局,限制服务选择器和凭据输入宽度,并统一文件面板控件尺寸。
- 更新命令分组导航、排序和新增操作的布局。
- 新增各受支持 Agent 的集成开关,安装与移除时保留其他受管理及用户已有 Hook 项。
- 改进:Markdown 默认以只读方式渲染,保留文字选择、复制、代码复制及公式操作;编辑需要显式切换到源码模式。
- 改进:阅读模式仍可选择代码块语言,只改变语法高亮,不改写 Markdown;滚动离开再返回后保留选择,复制得到的代码内容不变。
- 改进:从源码模式返回阅读页时保留草稿,同时阻止只读模式下的撤销、重做快捷键修改文档。
- 改进:Markdown 使用虚拟列表、可见代码块和图片资源管理,并释放非活动文档的布局缓存,减少重复工作;不把单机结果写成固定的内存或速度承诺。
- 改进:Git/SVN 抽屉复用仓库快照,仅渲染可见条目,减少大型仓库中的重复工作。
- 改进:Agents 页面整体居中,九种 Agent 图标使用放大的 SVG 资源;CLI 是否存在与 Hook 是否安装分别展示,长路径可通过悬停提示查看。
- 改进:设置搜索、SSH 连接卡片和筛选、文件面板控件对齐,以及命令分组的导航、排序和新增操作。
- 改进:安装或移除受管理的 Agent Hook 时,保留其他集成及用户已有配置。
- 改进:暂时隐藏设置侧栏和搜索中的备份入口,本版不开放云备份控件。
Windows 提供安装器和 ZIP 便携包,Linux 和 macOS 包继续标记为 Preview。本候选用于安装测试;视觉验收和所有外部 CLI 的实际关窗恢复仍需单独验证。
Windows x64 提供安装器和 ZIP 便携包,Windows ARM64 提供原生便携 ZIP;安装器和应用内自动安装仍仅支持 x64。Linux x64 包及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。macOS 最低版本为 14。分子结构渲染仍处于禁用状态。
## Contributors
<a href="https://github.com/Traveritas"><img src="https://github.com/Traveritas.png?size=96" width="64" height="64" alt="@Traveritas avatar"></a><a href="https://github.com/galact-byte"><img src="https://github.com/galact-byte.png?size=96" width="64" height="64" alt="@galact-byte avatar"></a><a href="https://github.com/Kuddev"><img src="https://github.com/Kuddev.png?size=96" width="64" height="64" alt="@Kuddev avatar"></a>
<a href="https://github.com/Traveritas"><img src="https://github.com/Traveritas.png?size=96" width="64" height="64" alt="@Traveritas avatar"></a><a href="https://github.com/galact-byte"><img src="https://github.com/galact-byte.png?size=96" width="64" height="64" alt="@galact-byte avatar"></a><a href="https://github.com/821869798"><img src="https://github.com/821869798.png?size=96" width="64" height="64" alt="@821869798 avatar"></a><a href="https://github.com/tataraDM"><img src="https://github.com/tataraDM.png?size=96" width="64" height="64" alt="@tataraDM avatar"></a><a href="https://github.com/Aschenbath"><img src="https://github.com/Aschenbath.png?size=96" width="64" height="64" alt="@Aschenbath avatar"></a><a href="https://github.com/Kuddev"><img src="https://github.com/Kuddev.png?size=96" width="64" height="64" alt="@Kuddev avatar"></a>
- **[@Traveritas](https://github.com/Traveritas)** — Kimi Code integration. / Kimi Code 集成。([#197](https://github.com/Kuddev/pebrel/pull/197))
- **[@galact-byte](https://github.com/galact-byte)** — CMD interrupts and activity, application colors and Pi notification outcomes. / CMD 中断与活动、应用配色及 Pi 通知结果。([#206](https://github.com/Kuddev/pebrel/pull/206), [#207](https://github.com/Kuddev/pebrel/pull/207), [#208](https://github.com/Kuddev/pebrel/pull/208), [#215](https://github.com/Kuddev/pebrel/pull/215), [#216](https://github.com/Kuddev/pebrel/pull/216))
- **[@Kuddev](https://github.com/Kuddev)** — Desktop integration, notifications, restoration and editor fixes. / 桌面集成、通知、恢复及编辑器修复。([#221](https://github.com/Kuddev/pebrel/pull/221), [#225](https://github.com/Kuddev/pebrel/pull/225), [#227](https://github.com/Kuddev/pebrel/pull/227), [#231](https://github.com/Kuddev/pebrel/pull/231), [#232](https://github.com/Kuddev/pebrel/pull/232))
- **[@Traveritas](https://github.com/Traveritas)** — Kimi Code hooks, recognition and branding. / Kimi Code Hook、识别和品牌图标。([#197](https://github.com/Kuddev/pebrel/pull/197))
- **[@galact-byte](https://github.com/galact-byte)** — CMD interrupts and activity, terminal application colors and Pi notification outcomes. / CMD 中断与活动、终端应用配色及 Pi 通知结果。([#206](https://github.com/Kuddev/pebrel/pull/206), [#207](https://github.com/Kuddev/pebrel/pull/207), [#208](https://github.com/Kuddev/pebrel/pull/208), [#215](https://github.com/Kuddev/pebrel/pull/215), [#216](https://github.com/Kuddev/pebrel/pull/216))
- **[@821869798](https://github.com/821869798)** — Markdown links and local file opening from terminals and the answer reader. / 终端及回答阅读页的 Markdown 链接和本地文件打开。([#204](https://github.com/Kuddev/pebrel/pull/204))
- **[@tataraDM](https://github.com/tataraDM)** — Readable single-line Markdown outline headings. / 修复 Markdown 目录长标题挤压。([#191](https://github.com/Kuddev/pebrel/pull/191))
- **[@Aschenbath](https://github.com/Aschenbath)** — Configurable notification duration and persistent notifications. / 可配置的通知停留时长和常驻通知。([#131](https://github.com/Kuddev/pebrel/pull/131))
- **[@Kuddev](https://github.com/Kuddev)** — Desktop integration, actionable notifications, session restoration, Markdown reader, Agents UI, review and release integration. / 桌面整合、可操作通知、会话恢复、Markdown 阅读、Agents 界面、审核及发布整合。([#210](https://github.com/Kuddev/pebrel/pull/210), [#217](https://github.com/Kuddev/pebrel/pull/217), [#221](https://github.com/Kuddev/pebrel/pull/221), [#222](https://github.com/Kuddev/pebrel/pull/222), [#225](https://github.com/Kuddev/pebrel/pull/225), [#227](https://github.com/Kuddev/pebrel/pull/227), [#231](https://github.com/Kuddev/pebrel/pull/231), [#232](https://github.com/Kuddev/pebrel/pull/232))
---
**SHA256**
- `Pebrel-v1.9.0-linux-x64-preview.AppImage`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-linux-x64-preview.deb`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-macos-arm64-preview.dmg`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-macos-x64-preview.dmg`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-windows-x64.zip`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-windows-x64-setup.exe`: `PENDING FINAL BUILD`
- `Pebrel-v1.9.0-linux-x64-preview.AppImage`: `4da5ea590a82ab4f89dca14df1399e7c2cd27fa4fe308da6f0e95b8aa9ae7d22`
- `Pebrel-v1.9.0-linux-x64-preview.deb`: `bb6fff964942b750a794f2ea148e851719430dfffd9bb086a13e2f1e67d87722`
- `Pebrel-v1.9.0-linux-x64-preview.tar.gz`: `6526ed5f2f883bb5ca9600d2685c929056fbae9f2450259f34d65093a18deda6`
- `Pebrel-v1.9.0-macos-arm64-preview.dmg`: `daf2c6c381fbdf84c33c132e9c6c247e9f9dc6e86292cc5be855e635e6cfa6f3`
- `Pebrel-v1.9.0-macos-x64-preview.dmg`: `9f3f1465be9e1f3489be09ccf336d5bdc0a6ae40185062f677a6e857d47e1fe0`
- `Pebrel-v1.9.0-windows-x64.zip`: `1c219946450c7d93993394fd6d40c6e234d77547c9b4a98106c0d33ada9c27f9`
- `Pebrel-v1.9.0-windows-x64-setup.exe`: `52cefcdae9cd98035bcdd45944c020fc9acf9c2c497147ba337ed15069a07fe6`
- `Pebrel-v1.9.0-windows-arm64.zip`: `e2d062089afb19ddb9e3d078cc7f460a1d67cb36d95400cebab837f9fabda092`
+13
View File
@@ -92,6 +92,19 @@ by Git and are not required by the asset tests.
## Third-Party Logo Assets
### Agents settings vectors
The Agents settings page renders 24 logical pixel SVG marks rather than enlarging
the sidebar PNG textures. Claude, Kimi and Oh My Pi reuse the SVG sources below;
Cursor and Copilot reuse the existing settings icons. OpenAI, OpenCode, Pi and
Grok use `nebula_app/assets/icons/nebula-agent-{openai,opencode,pi,grok}.svg`,
retrieved from the fixed npm package `@lobehub/icons-static-svg` 1.95.0:
`https://unpkg.com/@lobehub/icons-static-svg@1.95.0/icons/{name}.svg`.
These four sources are unchanged except for a final newline, under the collection's
[MIT license](LICENSE-lobe-icons). Product trademarks belong to their owners.
GPUI renders the SVG alpha masks using theme ink, with orange for Claude and
purple for Oh My Pi; sidebar PNG presentation is independent.
### Cursor and GitHub Copilot settings icons
`nebula_app/assets/icons/nebula-agent-cursor.svg` and
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

After

Width:  |  Height:  |  Size: 757 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>opencode</title><path d="M16 6H8v12h8V6zm4 16H4V2h16v20z"></path></svg>

After

Width:  |  Height:  |  Size: 236 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Pi</title><path clip-rule="evenodd" d="M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z"></path><path d="M17.5 12H23v11h-5.5V12z"></path></svg>

After

Width:  |  Height:  |  Size: 314 B

+3 -3
View File
@@ -170,8 +170,8 @@
"platform_unsupported": "Automatic hook installation is currently available on Windows.",
"checking": "Checking installed agents and hooks…",
"applying": "Applying…",
"installed": "Notifications on",
"off": "Notifications off",
"installed": "Hook installed",
"off": "Hook not installed",
"not_detected": "Not installed",
"config_detected": "Agent configuration detected",
"unsupported": "Hook integration not available yet",
@@ -182,7 +182,7 @@
"disabled": "Hooks removed. Restart the agent to apply.",
"failed": "Could not update hooks: {error}",
"helper_missing": "The hook helper is missing. Repair the Pebrel installation to enable hooks.",
"needs_repair": "Installed · needs repair",
"needs_repair": "Hook installed · needs repair",
"initialize": "Run this agent once to create its configuration, then refresh.",
"config_path": "Configuration: {path}"
}
+3 -3
View File
@@ -170,8 +170,8 @@
"platform_unsupported": "目前仅 Windows 支持自动安装 hooks。",
"checking": "正在检测工具和 hooks…",
"applying": "处理中…",
"installed": "已开启通知",
"off": "未开启通知",
"installed": "Hook 已安装",
"off": "Hook 未安装",
"not_detected": "未安装",
"config_detected": "已检测到工具配置",
"unsupported": "尚未提供 hook 接入",
@@ -182,7 +182,7 @@
"disabled": "Hooks 已移除,请重启 agent 使其生效。",
"failed": "无法更新 hooks:{error}",
"helper_missing": "缺少 hook 辅助程序,请修复 Pebrel 安装后再启用。",
"needs_repair": "已安装 · 需修复",
"needs_repair": "Hook 已安装 · 需修复",
"initialize": "请先运行一次此工具以创建配置,然后刷新。",
"config_path": "配置:{path}"
}
+3 -3
View File
@@ -51,8 +51,8 @@
"platform_unsupported": "目前僅 Windows 支援自動安裝 hooks。",
"checking": "正在偵測工具與 hooks…",
"applying": "處理中…",
"installed": "已開啟通知",
"off": "未開啟通知",
"installed": "Hook 已安裝",
"off": "Hook 未安裝",
"not_detected": "未安裝",
"config_detected": "已偵測到工具設定",
"unsupported": "尚未提供 hook 整合",
@@ -63,7 +63,7 @@
"disabled": "Hooks 已移除,請重新啟動 agent 使其生效。",
"failed": "無法更新 hooks:{error}",
"helper_missing": "缺少 hook 輔助程式,請修復 Pebrel 安裝後再啟用。",
"needs_repair": "已安裝 · 需修復",
"needs_repair": "Hook 已安裝 · 需修復",
"initialize": "請先執行一次此工具以建立設定,然後重新整理。",
"config_path": "設定:{path}"
}
+21 -1
View File
@@ -39,6 +39,16 @@ const NEBULA_ICONS: &[(&str, &[u8])] = icons![
"vcs-conflict",
"agent-cursor",
"agent-copilot",
"agent-openai",
"agent-opencode",
"agent-pi",
"agent-grok",
];
const AGENT_ICONS: &[(&str, &[u8])] = &[
("icons/agent-claude.svg", include_bytes!("../../../extra/logo/ai_claude.svg")),
("icons/agent-kimi.svg", include_bytes!("../../../extra/logo/ai_kimi.svg")),
("icons/agent-omp.svg", include_bytes!("../../../extra/logo/ai_omp.svg")),
];
/// 先查本仓库,未命中再交给组件库。
@@ -46,7 +56,9 @@ pub struct NebulaAssets;
impl AssetSource for NebulaAssets {
fn load(&self, path: &str) -> Result<Option<Cow<'static, [u8]>>> {
if let Some((_, bytes)) = NEBULA_ICONS.iter().find(|(name, _)| *name == path) {
if let Some((_, bytes)) =
NEBULA_ICONS.iter().chain(AGENT_ICONS).find(|(name, _)| *name == path)
{
return Ok(Some(Cow::Borrowed(bytes)));
}
gpui_component_assets::Assets.load(path)
@@ -57,6 +69,7 @@ impl AssetSource for NebulaAssets {
names.extend(
NEBULA_ICONS
.iter()
.chain(AGENT_ICONS)
.filter(|(name, _)| name.starts_with(path))
.map(|(name, _)| SharedString::from(*name)),
);
@@ -66,6 +79,13 @@ impl AssetSource for NebulaAssets {
/// 自带图标的路径。走路径而不是 `IconName`:扩展那个枚举等于改 fork。
pub mod nav {
pub const AGENT_CLAUDE: &str = "icons/agent-claude.svg";
pub const AGENT_KIMI: &str = "icons/agent-kimi.svg";
pub const AGENT_OMP: &str = "icons/agent-omp.svg";
pub const AGENT_OPENAI: &str = "icons/nebula-agent-openai.svg";
pub const AGENT_OPENCODE: &str = "icons/nebula-agent-opencode.svg";
pub const AGENT_PI: &str = "icons/nebula-agent-pi.svg";
pub const AGENT_GROK: &str = "icons/nebula-agent-grok.svg";
pub const AGENT_CURSOR: &str = "icons/nebula-agent-cursor.svg";
pub const AGENT_COPILOT: &str = "icons/nebula-agent-copilot.svg";
pub const LAYOUT_GRID: &str = "icons/nebula-layout-grid.svg";
@@ -69,7 +69,7 @@ mod tests {
let path = directory.path().join("activity.md");
let original = format!("# 中文标题\n\n正文\n\n{}", "后面的段落\n\n".repeat(60));
std::fs::write(&path, &original).unwrap();
let (file, mut cx) = super::super::tests::open(path.clone(), cx);
let (file, mut cx) = super::super::tests::open_live(path.clone(), cx);
cx.update(|window, cx| file.update(cx, |view, cx| view.begin_live_edit(1, window, cx)));
cx.simulate_input("未保存🌿");
cx.run_until_parked();
@@ -260,6 +260,7 @@ fn render_language_picker(
cx: &mut App,
) -> impl IntoElement {
let colors = code_ui_colors(cx);
let current_selector = current.clone();
let picker = div()
.id("markdown-language-picker")
.debug_selector(|| "markdown-language-picker".to_owned())
@@ -287,7 +288,12 @@ fn render_language_picker(
// the latter remains discoverable through focus-visible.
.focus_visible(|trigger| trigger.border_1().border_color(colors.accent))
.hover(move |trigger| trigger.bg(colors.hover))
.child(div().flex_none().child(current))
.child(
div()
.debug_selector(move || format!("markdown-language-current-{current_selector}"))
.flex_none()
.child(current),
)
.child(
div()
.w(px(16.0))
@@ -392,11 +398,18 @@ pub(super) fn extensions(
pub(super) fn render(
owner: gpui::WeakEntity<TextFileView>,
block: usize,
code: CodeSpec,
mut code: CodeSpec,
hover_group: SharedString,
window: &mut Window,
cx: &mut App,
) -> gpui::AnyElement {
if let Some((start, _)) = code.span
&& let Some(language) = owner
.upgrade()
.and_then(|view| view.read(cx).preview_code_languages.get(&(block, start)).cloned())
{
code.language = Some(language);
}
render_with_input(owner, block, code, hover_group, None, window, cx)
}
@@ -415,7 +428,7 @@ pub(super) fn render_with_input(
let current = code
.language
.clone()
.filter(|value| !matches!(value.as_ref(), "text" | "plaintext"))
.filter(|value| !matches!(value.as_ref(), "" | "text" | "plaintext"))
.unwrap_or_else(|| plain.clone());
let current_for_state = current.clone();
let span = code.span;
@@ -450,8 +463,13 @@ pub(super) fn render_with_input(
.map(|item| item.name.clone());
if let Some(choice) = choice {
if let Some((start, end)) = state.span {
let choice =
if choice == state.plain { "" } else { choice.as_ref() };
let choice = if choice == state.plain
|| matches!(choice.as_ref(), "text" | "plaintext")
{
""
} else {
choice.as_ref()
};
let _ = owner.update(cx, |view, cx| {
view.set_preview_language(block, start, end, choice, window, cx)
});
@@ -17,6 +17,7 @@ fn open(path: PathBuf, cx: &mut TestAppContext) -> (Entity<TextFileView>, Visual
let mut file = None;
let (_, window) = cx.add_window_view(|window, cx| {
let view = cx.new(|cx| TextFileView::new(path, window, cx));
view.update(cx, |view, _| view.live_mode = true);
file = Some(view.clone());
Root::new(view, window, cx)
});
@@ -36,6 +37,68 @@ fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_event(gpui::KeyUpEvent { keystroke });
}
#[gpui::test]
fn read_only_language_picker_changes_highlighting_without_editing_source(cx: &mut TestAppContext) {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("reader-language.md");
let source = format!("```text\nfn main() {{}}\n```\n\n{}", "Trailing paragraph\n\n".repeat(80));
std::fs::write(&path, &source).unwrap();
let (file, mut cx) = super::tests::open(path.clone(), cx);
let draw = |cx: &mut VisualTestContext| {
cx.update(|window, cx| {
window.draw(cx).clear(cx);
});
cx.run_until_parked();
};
for choice in ["rust", "plaintext"] {
draw(&mut cx);
let picker = cx.debug_bounds("markdown-language-picker").unwrap();
cx.simulate_click(picker.center(), Modifiers::default());
cx.run_until_parked();
cx.simulate_keystrokes(match crate::platform::Platform::current() {
crate::platform::Platform::MacOS => "cmd-a",
_ => "ctrl-a",
});
cx.simulate_input(choice);
cx.run_until_parked();
press("enter", &mut cx);
cx.run_until_parked();
draw(&mut cx);
file.read_with(&cx, |view, cx| {
assert!(view.preview && !view.live_mode && view.live_edit.is_none());
assert_eq!(view.draft(cx).as_ref(), source);
assert!(!view.dirty);
assert_eq!(
view.preview_code_languages.get(&(0, 0)).map(|value| value.as_ref()),
Some(if choice == "plaintext" { "" } else { choice }),
);
});
if choice == "rust" {
assert!(cx.debug_bounds("markdown-language-current-rust").is_some());
// Virtualizing the code block must not discard this document's display choice.
file.update(&mut cx, |view, cx| {
view.scroll.scroll_to_reveal_item(70);
cx.notify();
});
draw(&mut cx);
file.update(&mut cx, |view, cx| {
view.scroll.scroll_to_reveal_item(0);
cx.notify();
});
draw(&mut cx);
assert!(cx.debug_bounds("markdown-language-current-rust").is_some());
}
let code = cx.debug_bounds("pebrel-code-block").unwrap();
cx.simulate_mouse_move(code.center(), None, Modifiers::default());
draw(&mut cx);
let copy = cx.debug_bounds("markdown-copy-code").unwrap();
cx.simulate_click(copy.center(), Modifiers::default());
cx.run_until_parked();
assert_eq!(cx.read_from_clipboard().unwrap().text().unwrap().trim_end(), "fn main() {}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), source);
}
}
#[gpui::test]
fn picker_trigger_popup_search_and_rows_keep_html_geometry(cx: &mut TestAppContext) {
let directory = tempfile::tempdir().unwrap();
@@ -15,7 +15,7 @@ fn open(
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("inline-live.md");
std::fs::write(&path, source).unwrap();
let (file, window) = tests::open(path, cx);
let (file, window) = tests::open_live(path, cx);
(directory, file, window)
}
@@ -47,7 +47,7 @@ fn second_identical_formula_edits_only_its_source_and_keeps_other_objects(cx: &m
let path = dir.path().join("objects.md");
let source = "**Before** $x^2$ between $x^2$ **After**";
std::fs::write(&path, source).unwrap();
let (file, mut cx) = tests::open(path.clone(), cx);
let (file, mut cx) = tests::open_live(path.clone(), cx);
let first = part_for(&file, "$x^2$", 0, &cx);
let second = part_for(&file, "$x^2$", 1, &cx);
draw(&mut cx);
@@ -95,7 +95,7 @@ fn image_source_is_local_and_surrounding_formula_stays_rendered(cx: &mut TestApp
let path = dir.path().join("image.md");
let source = "Before ![alt](image.png) $y^2$ **After**";
std::fs::write(&path, source).unwrap();
let (file, mut cx) = tests::open(path, cx);
let (file, mut cx) = tests::open_live(path, cx);
let image_part = part_for(&file, "![alt](image.png)", 0, &cx);
click_part(image_part, &mut cx);
file.read_with(&cx, |view, cx| {
@@ -36,7 +36,10 @@ impl TextFileView {
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.loading || self.document.as_ref().is_none_or(|doc| doc.read_only) {
if (self.preview && !self.preview_editable())
|| self.loading
|| self.document.as_ref().is_none_or(|doc| doc.read_only)
{
return;
}
self.history.record(&self.input.read(cx).value());
@@ -60,6 +60,15 @@ pub(super) fn decorations(projection: &Projection, cx: &App) -> Vec<TextDecorati
}
impl TextFileView {
pub(super) fn preview_editable(&self) -> bool {
self.live_mode
&& self.render_active
&& self.preview
&& !self.loading
&& !self.outline.limited
&& self.document.as_ref().is_some_and(|document| !document.read_only)
}
pub(super) fn document_input_focused(&self, window: &Window, cx: &App) -> bool {
self.focus.is_focused(window)
|| (!self.preview && self.input.read(cx).focus_handle(cx).is_focused(window))
@@ -111,13 +120,7 @@ impl TextFileView {
return;
}
}
if !self.live_mode
|| !self.render_active
|| !self.preview
|| self.loading
|| self.outline.limited
|| self.document.as_ref().is_none_or(|document| document.read_only)
{
if !self.preview_editable() {
return;
}
if self.live_edit.as_ref().is_some_and(|edit| edit.block == block && edit.part == part) {
@@ -10,7 +10,7 @@ fn unchanged_activation_skips_parse_without_losing_an_earlier_pending_edit(
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("activation.md");
std::fs::write(&path, "# Heading\n\n中文段落\n").unwrap();
let (file, mut cx) = tests::open(path, cx);
let (file, mut cx) = tests::open_live(path, cx);
cx.update(|_, cx| file.update(cx, |view, cx| view.finish_live_edit(cx)));
cx.run_until_parked();
cx.update(|window, cx| {
@@ -55,7 +55,7 @@ fn formatted_edit_saves_source_without_rewriting_other_blocks(cx: &mut TestAppCo
let path = directory.path().join("inline.md");
let original = "## 中文 **标题** ##\n\n[link](https://example.test \"title\")\n\n~~~rust\nlet n = 1;\n~~~\n";
std::fs::write(&path, original).unwrap();
let (file, mut cx) = tests::open(path.clone(), cx);
let (file, mut cx) = tests::open_live(path.clone(), cx);
let input = cx.update(|window, cx| {
file.update(cx, |view, cx| {
view.live_mode = true;
@@ -83,7 +83,7 @@ fn undo_redo_crosses_formatted_source_and_finished_blocks(cx: &mut TestAppContex
let path = directory.path().join("history.md");
let original = "# Title\n\n**Body**\n";
std::fs::write(&path, original).unwrap();
let (file, mut cx) = tests::open(path, cx);
let (file, mut cx) = tests::open_live(path, cx);
cx.update(|window, cx| {
file.update(cx, |view, cx| {
view.live_mode = true;
@@ -113,7 +113,7 @@ fn composition_keeps_native_input_identity_and_marked_selection(cx: &mut TestApp
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("ime.md");
std::fs::write(&path, "**你好**\n").unwrap();
let (file, mut cx) = tests::open(path, cx);
let (file, mut cx) = tests::open_live(path, cx);
let input = cx.update(|window, cx| {
file.update(cx, |view, cx| {
view.live_mode = true;
@@ -140,7 +140,7 @@ fn composition_before_the_first_layout_keeps_its_caret(cx: &mut TestAppContext)
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("first-frame.md");
std::fs::write(&path, "你好").unwrap();
let (file, mut cx) = tests::open(path, cx);
let (file, mut cx) = tests::open_live(path, cx);
let input = cx.update(|window, cx| {
let input = file.update(cx, |view, cx| {
let click = gpui::ClickEvent::Mouse(gpui::MouseClickEvent {
@@ -167,7 +167,7 @@ fn enter_creates_an_editable_paragraph_and_undo_restores_the_heading(cx: &mut Te
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("paragraph.md");
std::fs::write(&path, "# Title").unwrap();
let (file, mut cx) = tests::open(path, cx);
let (file, mut cx) = tests::open_live(path, cx);
cx.update(|window, cx| {
file.update(cx, |view, cx| {
view.live_mode = true;
@@ -195,7 +195,7 @@ fn clicking_near_the_start_places_the_caret_near_the_start(cx: &mut TestAppConte
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("caret.md");
std::fs::write(&path, "A paragraph with a long tail.").unwrap();
let (file, mut cx) = tests::open(path, cx);
let (file, mut cx) = tests::open_live(path, cx);
cx.update(|window, cx| {
file.update(cx, |view, cx| {
view.live_mode = true;
@@ -224,7 +224,7 @@ fn first_click_uses_painted_glyphs_after_the_frame_callback(cx: &mut TestAppCont
let path = directory.path().join("first-click.md");
let source = "中文🌿 mixed text with a long tail for positioning.";
std::fs::write(&path, source).unwrap();
let (file, mut cx) = tests::open(path, cx);
let (file, mut cx) = tests::open_live(path, cx);
cx.update(|window, cx| {
let _ = window.draw(cx);
});
@@ -264,7 +264,7 @@ fn activating_heading_preserves_following_paragraph_position(cx: &mut TestAppCon
let path = directory.path().join(format!("heading-{scale}-{level}.md"));
std::fs::write(&path, format!("{} A heading\n\nFollowing paragraph", "#".repeat(level)))
.unwrap();
let (file, mut visual) = tests::open(path, cx);
let (file, mut visual) = tests::open_live(path, cx);
visual.update(|window, cx| {
window.set_scale_factor(scale);
file.update(cx, |_, cx| cx.notify());
+6 -1
View File
@@ -128,6 +128,8 @@ pub struct TextFileView {
inline_views:
Rc<RefCell<std::collections::BTreeMap<(usize, usize), gpui::WeakEntity<TextViewState>>>>,
preview_extensions: gpui_component::text::MarkdownExtensions,
/// Reading-only syntax choices survive virtual row eviction, but never change source.
preview_code_languages: std::collections::HashMap<(usize, usize), SharedString>,
preview_images: Entity<image_cache::DocumentImageCache>,
scroll: ListState,
preview_selection_scroll_epoch: u64,
@@ -229,6 +231,7 @@ impl TextFileView {
let mut this = Self {
preview_images: image_cache::DocumentImageCache::new(cx.entity_id(), cx),
preview_extensions,
preview_code_languages: Default::default(),
path,
title,
input,
@@ -243,7 +246,8 @@ impl TextFileView {
notice: None,
markdown,
preview: markdown,
live_mode: markdown,
// Rendered Markdown opens as a selectable reader. Editing is explicit via source mode.
live_mode: false,
live_edit: None,
render_active: true,
preview_stale: false,
@@ -550,6 +554,7 @@ impl TextFileView {
fn apply_outline(&mut self, outline: Outline, cx: &mut Context<Self>) {
self.preview_stale = false;
self.preview_code_languages.clear();
self.inline_views.borrow_mut().clear();
let top = self.scroll.logical_scroll_top();
self.blocks = Rc::new(RefCell::new(vec![None; outline.blocks.len()]));
@@ -515,9 +515,17 @@ impl TextFileView {
window: &mut Window,
cx: &mut Context<Self>,
) {
let key = (block, start);
let Some((start, end)) = self.outline.source_span(block, start, end) else { return };
let Some(source) = self.outline.blocks.get(block) else { return };
let Some(next) = fence_language(source, start, end, language) else { return };
if !self.preview_editable() {
if self.preview && self.render_active && !self.loading {
self.preview_code_languages.insert(key, language.to_owned().into());
cx.notify();
}
return;
}
let Some(range) = self.outline.source_ranges.get(block).cloned() else { return };
self.commit_structure_edit(range, &next, None, window, cx);
}
@@ -30,7 +30,7 @@ impl TextFileView {
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.loading || self.document.as_ref().is_none_or(|document| document.read_only) {
if !self.preview_editable() {
return;
}
self.update_live_edit(window, cx);
@@ -11,7 +11,7 @@ fn open(
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("structured.md");
std::fs::write(&path, source).unwrap();
let (view, window) = tests::open(path, cx);
let (view, window) = tests::open_live(path, cx);
(directory, view, window)
}
@@ -68,7 +68,11 @@ impl TextFileView {
let input = live.then(|| self.render_live_input(cx));
let spec = super::code_actions::CodeSpec {
source: source[structure.parts[*part].range.clone()].to_owned().into(),
language: (!language.is_empty()).then(|| language.clone().into()),
language: self
.preview_code_languages
.get(&(block, span.start))
.cloned()
.or_else(|| (!language.is_empty()).then(|| language.clone().into())),
span: Some((span.start, span.end)),
};
let part = *part;
@@ -153,6 +157,7 @@ impl TextFileView {
let check = check.clone();
Button::new(("markdown-task", item.range.start))
.ghost()
.disabled(!self.preview_editable())
.size(px(24.0))
.p_0()
.flex_shrink_0()
+105 -4
View File
@@ -5,6 +5,22 @@ use gpui_component::Root;
pub(super) fn open(
path: PathBuf,
cx: &mut TestAppContext,
) -> (Entity<TextFileView>, VisualTestContext) {
open_with_mode(path, false, cx)
}
/// Existing live-editor regressions opt in explicitly; the product opens in reader mode.
pub(super) fn open_live(
path: PathBuf,
cx: &mut TestAppContext,
) -> (Entity<TextFileView>, VisualTestContext) {
open_with_mode(path, true, cx)
}
fn open_with_mode(
path: PathBuf,
live_mode: bool,
cx: &mut TestAppContext,
) -> (Entity<TextFileView>, VisualTestContext) {
cx.update(|cx| {
gpui_component::init(cx);
@@ -13,7 +29,13 @@ pub(super) fn open(
});
let mut file = None;
let (_, window) = cx.add_window_view(|window, cx| {
let view = cx.new(|cx| TextFileView::new(path, window, cx));
let view = cx.new(|cx| {
let mut view = TextFileView::new(path, window, cx);
if live_mode {
view.live_mode = true;
}
view
});
file = Some(view.clone());
Root::new(view, window, cx)
});
@@ -21,6 +43,85 @@ pub(super) fn open(
(file.unwrap(), window.clone())
}
#[gpui::test]
fn default_reader_blocks_edits_but_keeps_selection_and_code_copy(cx: &mut TestAppContext) {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("reader.md");
let source = "# Title\n\nText to select\n\n- [ ] Task\n\n```rust\nlet x = 42;\n```\n";
std::fs::write(&path, source).unwrap();
let (file, mut cx) = open(path.clone(), cx);
cx.simulate_resize(gpui::size(px(1100.0), px(1000.0)));
for selector in ["markdown-preview-block-1", "markdown-task-box-2", "pebrel-code-text"] {
cx.update(|window, cx| {
window.draw(cx).clear(cx);
});
let bounds = cx.debug_bounds(selector).expect(selector);
cx.simulate_click(bounds.center(), gpui::Modifiers::default());
cx.simulate_input("must not edit");
cx.simulate_keystrokes("backspace");
cx.run_until_parked();
file.read_with(&cx, |view, cx| {
assert!(view.preview && !view.live_mode && view.live_edit.is_none());
assert_eq!(view.draft(cx), source);
assert!(!view.dirty);
});
}
assert!(cx.debug_bounds("markdown-language-picker").is_some());
// Read-only applies to commands too, not just the visible controls.
cx.update(|window, cx| {
file.update(cx, |view, cx| {
view.commit_structure_edit(0..1, "changed", None, window, cx);
view.focus.focus(window, cx);
})
});
cx.simulate_keystrokes("ctrl-a ctrl-c");
cx.run_until_parked();
assert_eq!(cx.read_from_clipboard().unwrap().text().unwrap(), source);
cx.update(|window, cx| {
window.draw(cx).clear(cx);
});
let code = cx.debug_bounds("pebrel-code-block").unwrap();
cx.simulate_mouse_move(code.center(), None, gpui::Modifiers::default());
cx.update(|window, cx| {
window.draw(cx).clear(cx);
});
let copy = cx.debug_bounds("markdown-copy-code").unwrap();
cx.simulate_click(copy.center(), gpui::Modifiers::default());
cx.run_until_parked();
assert_eq!(cx.read_from_clipboard().unwrap().text().unwrap().trim_end(), "let x = 42;");
assert!(!file.read_with(&cx, |view, _| view.dirty));
assert_eq!(std::fs::read_to_string(path).unwrap(), source);
}
#[gpui::test]
fn returning_to_reader_keeps_source_edits_and_blocks_undo_until_source_is_open(
cx: &mut TestAppContext,
) {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("source-edit.md");
let source = "Original";
std::fs::write(&path, source).unwrap();
let (file, mut cx) = open(path, cx);
cx.update(|window, cx| file.read(cx).focus.clone().focus(window, cx));
let modifier = match crate::platform::Platform::current() {
crate::platform::Platform::MacOS => "cmd",
_ => "ctrl",
};
cx.simulate_keystrokes(&format!("{modifier}-/ {modifier}-a"));
cx.simulate_input("Updated");
cx.run_until_parked();
cx.simulate_keystrokes(&format!("{modifier}-/ {modifier}-z"));
cx.run_until_parked();
file.read_with(&cx, |view, cx| {
assert!(view.preview && !view.live_mode);
assert_eq!(view.draft(cx), "Updated");
assert!(view.dirty);
});
cx.simulate_keystrokes(&format!("{modifier}-/ {modifier}-z"));
cx.run_until_parked();
assert_eq!(file.read_with(&cx, |view, cx| view.draft(cx)), source);
}
#[gpui::test]
fn source_mode_releases_preview_views_without_changing_the_document(cx: &mut TestAppContext) {
let directory = tempfile::tempdir().unwrap();
@@ -110,7 +211,7 @@ fn outline_arrow_folds_without_navigating_or_rewriting_the_document(cx: &mut Tes
}
#[gpui::test]
fn document_opens_editable_and_source_shortcut_preserves_draft_and_details(
fn document_opens_read_only_and_source_shortcut_preserves_draft_and_details(
cx: &mut TestAppContext,
) {
let directory = tempfile::tempdir().unwrap();
@@ -118,7 +219,7 @@ fn document_opens_editable_and_source_shortcut_preserves_draft_and_details(
let source = "# Title\n\nOriginal text\n";
std::fs::write(&path, source).unwrap();
let (file, mut cx) = open(path, cx);
assert!(file.read_with(&cx, |view, _| view.preview && view.live_mode));
assert!(file.read_with(&cx, |view, _| view.preview && !view.live_mode));
cx.update(|window, cx| file.read(cx).focus.clone().focus(window, cx));
for preview in [false, true, false, true] {
cx.update(|window, cx| {
@@ -166,7 +267,7 @@ fn code_actions_copy_raw_source_and_search_languages(cx: &mut TestAppContext) {
let path = directory.path().join("code.md");
let source = "```text\nlet x = 42;\n```\n";
std::fs::write(&path, source).unwrap();
let (file, mut cx) = open(path, cx);
let (file, mut cx) = open_live(path, cx);
cx.update(|window, cx| {
let _ = window.draw(cx);
});
+14 -1
View File
@@ -63,6 +63,7 @@ mod notifications;
mod shell_picker;
#[cfg(all(test, feature = "gpui-test-support"))]
mod shell_picker_tests;
mod sponsor;
mod status;
mod theme_advanced;
mod theme_editor;
@@ -115,6 +116,8 @@ pub struct SettingsPane {
about_update: AboutUpdateState,
about_update_seq: u64,
about_last_checked: Option<String>,
/// 首页「项目与支持」→ 赞助商:独立页面,不是外链行。切换分区时清掉。
about_sponsor_open: bool,
settings_search_input: Entity<InputState>,
search_origin_section: Option<usize>,
/// 每项还带着自己的 `values` 表:`SelectState` 只认索引,而从代码侧
@@ -1295,6 +1298,7 @@ impl SettingsPane {
.into_any_element();
}
match self.active_section {
0 if self.about_sponsor_open => self.section_sponsor(cx),
0 => self.section_home(window, cx),
1 => self.section_appearance(window, cx),
2 => self.section_profiles(window, cx),
@@ -1379,6 +1383,7 @@ impl SettingsPane {
.when(!active, |item| item.text_color(muted).hover(|s| s.bg(hover_bg)))
.on_click(cx.listener(move |this, _, _, cx| {
this.active_section = ix;
this.about_sponsor_open = false;
cx.notify();
}))
.child(
@@ -1536,7 +1541,15 @@ impl Render for SettingsPane {
.w_full()
.flex()
.justify_center()
.child(v_flex().w_full().when(application_page, |content| content.max_w(px(960.0))).child(content)),
.child(
v_flex()
.w_full()
.when(matches!(self.active_section, 9 | 10), |content| {
content.items_center()
})
.when(application_page, |content| content.max_w(px(960.0)))
.child(content),
),
),
),
)
@@ -8,6 +8,31 @@ impl SettingsPane {
url: String,
cx: &Context<Self>,
) -> gpui::AnyElement {
Self::about_row(id, icon, title, IconName::ExternalLink, cx)
.on_click(move |_, _, cx| cx.open_url(&url))
.into_any_element()
}
/// 与外链行同一条骨架,尾标是「进入页面」而不是「离开应用」。
pub(super) fn about_page_row(
id: &'static str,
icon: IconName,
title: &'static str,
on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static,
cx: &Context<Self>,
) -> gpui::AnyElement {
Self::about_row(id, icon, title, IconName::ChevronRight, cx)
.on_click(on_click)
.into_any_element()
}
fn about_row(
id: &'static str,
icon: IconName,
title: &'static str,
trailing: IconName,
cx: &Context<Self>,
) -> gpui::Stateful<gpui::Div> {
let muted = cx.theme().muted_foreground;
let hover = cx.theme().list_hover;
h_flex()
@@ -21,11 +46,9 @@ impl SettingsPane {
.rounded_md()
.cursor_pointer()
.hover(move |row| row.bg(hover))
.on_click(move |_, _, cx| cx.open_url(&url))
.child(Icon::new(icon).small().text_color(muted))
.child(div().flex_1().min_w_0().child(title))
.child(Icon::new(IconName::ExternalLink).xsmall().text_color(muted))
.into_any_element()
.child(Icon::new(trailing).xsmall().text_color(muted))
}
pub(super) fn about_value_row(
@@ -252,6 +275,13 @@ impl SettingsPane {
language.pick("更新内容", "Release notes"),
crate::update_check::RELEASES_PAGE.to_owned(),
cx,
))
.child(Self::about_page_row(
"about-sponsors",
IconName::Heart,
language.pick("赞助商", "Sponsors"),
cx.listener(|this, _, _, cx| this.open_sponsor_page(cx)),
cx,
));
v_flex().w_full().child(identity).child(
@@ -2,9 +2,6 @@ use super::*;
use crate::ai_agents::AgentKind;
use crate::ai_hook::integrations::{self, AgentIntegration};
use crate::i18n::Message;
use std::collections::HashMap;
type AgentLogos = HashMap<(crate::display::AiLogo, bool), Arc<RenderImage>>;
#[cfg(all(test, feature = "gpui-test-support"))]
mod tests;
@@ -27,7 +24,6 @@ pub(super) struct AgentSettingsState {
sequence: u64,
feedback: Option<(AgentKind, Result<bool, String>)>,
focus: Vec<FocusHandle>,
logos: AgentLogos,
#[cfg(all(test, feature = "gpui-test-support"))]
test_operation: Option<TestOperation>,
}
@@ -41,7 +37,6 @@ impl AgentSettingsState {
sequence: 0,
feedback: None,
focus: integrations::AGENTS.iter().map(|_| cx.focus_handle().tab_stop(true)).collect(),
logos: HashMap::new(),
#[cfg(all(test, feature = "gpui-test-support"))]
test_operation: None,
}
@@ -49,11 +44,6 @@ impl AgentSettingsState {
}
impl SettingsPane {
pub(in crate::gpui_shell) fn set_agent_logos(&mut self, logos: &AgentLogos) {
// 直接共享侧边栏已经缩放好的纹理,打开设置不再解码原图或另建图片缓存。
self.agents.logos.clone_from(logos);
}
fn refresh_agents(&mut self, cx: &mut Context<Self>) {
if self.agents.loading || self.agents.busy.is_some() {
return;
@@ -237,6 +227,12 @@ impl SettingsPane {
let hover = crate::gpui_shell::theme::settings_hover_bg(cx, false);
let border = crate::gpui_shell::theme::settings_hairline(cx);
let ring = cx.theme().ring;
let executable = row
.executable
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| language.text(Message::SettingsAgentsNotDetected).to_owned());
let path_tooltip = executable.clone();
let text = v_flex()
.flex_1()
.min_w_0()
@@ -244,24 +240,49 @@ impl SettingsPane {
.child(
div().text_sm().font_weight(gpui::FontWeight::MEDIUM).child(agent.display_name()),
)
.child(div().text_xs().text_color(muted).child(language.text(status)))
.child(
div()
.id(("agent-cli-path", index))
.debug_selector(move || format!("agent-cli-path-{index}"))
.w_full()
.truncate()
.text_xs()
.text_color(muted)
.tooltip(move |window, cx| {
gpui_component::tooltip::Tooltip::new(path_tooltip.clone())
.build(window, cx)
})
.child(executable),
)
.when_some(note, |view, note| {
view.child(div().text_xs().text_color(muted).child(note))
});
use crate::gpui_shell::assets::nav;
let icon = if let Some(path) = match agent {
AgentKind::Cursor => Some(crate::gpui_shell::assets::nav::AGENT_CURSOR),
AgentKind::Copilot => Some(crate::gpui_shell::assets::nav::AGENT_COPILOT),
AgentKind::Claude => Some(nav::AGENT_CLAUDE),
AgentKind::Codex => Some(nav::AGENT_OPENAI),
AgentKind::OpenCode => Some(nav::AGENT_OPENCODE),
AgentKind::Cursor => Some(nav::AGENT_CURSOR),
AgentKind::Kimi => Some(nav::AGENT_KIMI),
AgentKind::Pi => Some(nav::AGENT_PI),
AgentKind::OhMyPi => Some(nav::AGENT_OMP),
AgentKind::Copilot => Some(nav::AGENT_COPILOT),
AgentKind::Grok => Some(nav::AGENT_GROK),
_ => None,
} {
Icon::default().path(path).size(px(16.0)).into_any_element()
} else if let Some(image) = crate::display::ai_logo_for_program(agent.slug())
.and_then(|logo| self.agents.logos.get(&(logo, cx.theme().is_dark())))
{
img(image.clone()).size(px(16.0)).into_any_element()
Icon::default()
.path(path)
.size(px(24.0))
.text_color(match agent {
AgentKind::Claude => rgb_hsla(217, 119, 87),
AgentKind::OhMyPi => rgb_hsla(147, 98, 239),
_ => cx.theme().foreground,
})
.into_any_element()
} else {
div()
.font_family(crate::font_install::REQUIRED_FONT_FAMILY)
.text_size(px(16.0))
.text_size(px(24.0))
.child(crate::display::program_icon(agent.slug()))
.into_any_element()
};
@@ -307,7 +328,7 @@ impl SettingsPane {
}))
.child(
div()
.size(px(28.0))
.size(px(32.0))
.flex_shrink_0()
.flex()
.items_center()
@@ -316,17 +337,34 @@ impl SettingsPane {
)
.child(text)
.child(
div().min_w(px(40.0)).min_h(px(32.0)).flex().items_center().justify_center().child(
crate::gpui_shell::widgets::NebulaSwitch::new(format!(
"agent-{}",
agent.slug()
))
.checked(checked)
.disabled(disabled)
.on_click(cx.listener(move |this, enabled, _, cx| {
this.toggle_agent_hook(agent, *enabled, cx)
})),
),
div()
.id(("agent-hook-status", index))
.debug_selector(move || format!("agent-hook-status-{index}"))
.flex_shrink_0()
.max_w(px(160.0))
.text_xs()
.text_color(muted)
.child(language.text(status)),
)
.child(
div()
.flex_shrink_0()
.min_w(px(40.0))
.min_h(px(32.0))
.flex()
.items_center()
.justify_center()
.child(
crate::gpui_shell::widgets::NebulaSwitch::new(format!(
"agent-{}",
agent.slug()
))
.checked(checked)
.disabled(disabled)
.on_click(cx.listener(
move |this, enabled, _, cx| this.toggle_agent_hook(agent, *enabled, cx),
)),
),
)
}
}
@@ -344,8 +382,6 @@ fn agent_status(row: &AgentIntegration, busy: bool) -> Message {
let state = &row.inspection;
if busy {
Message::SettingsAgentsApplying
} else if row.executable.is_none() {
Message::SettingsAgentsNotDetected
} else if state.installed && state.needs_repair {
Message::SettingsAgentsNeedsRepair
} else if state.installed {
@@ -62,6 +62,36 @@ fn fixture(
(pane.unwrap(), window, sender, calls)
}
#[gpui::test]
fn agents_content_is_centered_and_paths_leave_room_for_hook_status(cx: &mut TestAppContext) {
let (pane, window, reply, _) = fixture(cx);
drop(reply);
pane.update(window, |pane, cx| {
pane.agents.rows.as_mut().unwrap()[0].executable = Some(
"C:/Users/example/AppData/Local/a-very-long-installation-directory/bin/claude.exe"
.into(),
);
cx.notify();
});
for width in [1440.0, 800.0] {
window.simulate_resize(size(px(width), px(900.0)));
window.run_until_parked();
window.update(|window, cx| {
window.refresh();
window.draw(cx).clear(cx);
});
let row = window.debug_bounds("agent-hook-row-0").unwrap();
let path = window.debug_bounds("agent-cli-path-0").unwrap();
let status = window.debug_bounds("agent-hook-status-0").unwrap();
let center = px((width + SETTINGS_NAV_WIDTH) / 2.0);
assert!((f32::from(row.center().x - center)).abs() <= 2.0);
assert!(row.size.width <= px(720.0));
assert!(path.size.width > px(0.0));
assert!(path.right() <= status.left());
assert!(status.right() + px(40.0) <= row.right());
}
}
#[gpui::test]
fn installed_hook_row_accepts_padding_clicks_and_reports_write_failure(cx: &mut TestAppContext) {
if !crate::platform::CAPABILITIES.ai_hook_server {
@@ -118,8 +148,14 @@ fn configuration_without_an_executable_is_not_an_installed_agent(cx: &mut TestAp
let mut rows = rows(false);
rows[0].executable = None;
assert!(rows[0].inspection.available);
assert!(matches!(agent_status(&rows[0], false), Message::SettingsAgentsNotDetected));
assert!(matches!(
agent_status(&rows[0], false),
Message::SettingsAgentsOff | Message::SettingsAgentsUnavailable
));
assert!(!can_toggle(&rows[0]));
rows[0].inspection.installed = true;
assert!(matches!(agent_status(&rows[0], false), Message::SettingsAgentsInstalled));
rows[0].inspection.installed = false;
pane.agents.rows = Some(rows);
cx.notify();
});
@@ -32,6 +32,8 @@ fn pairing_design_backup_groups_scope_and_save_feedback_with_storage(
let card = cx.debug_bounds("cloud-storage-card").expect("storage configuration");
assert!(card.right() <= px(width));
assert!(card.size.width <= px(720.0));
let center = px((width + SETTINGS_NAV_WIDTH) / 2.0);
assert!((f32::from(card.center().x - center)).abs() <= 2.0);
let provider = cx.debug_bounds("cloud-provider").expect("bounded provider dropdown");
assert!(provider.size.width <= px(220.0));
assert!(provider.left() >= card.left() && provider.right() <= card.right());
@@ -584,6 +584,7 @@ impl SettingsPane {
about_update: AboutUpdateState::Idle,
about_update_seq: 0,
about_last_checked: None,
about_sponsor_open: false,
settings_search_input,
search_origin_section: None,
selects,
@@ -7,7 +7,7 @@ pub(super) const REPOSITORY_URL: &str = "https://github.com/Kuddev/pebrel";
pub(super) const BUG_REPORT_TEMPLATE: &str = "bug_report.yml";
/// 左侧分区的稳定路由表。2026-08-28 产品裁定:默认 GPUI 导航收敛为常用项,
/// 暂时隐藏“AI 供应商”;页面实现与索引继续保留。后续恢复入口时只改
/// 暂时隐藏“AI 供应商”和“备份”;页面实现与索引继续保留。后续恢复入口时只改
/// [`HIDDEN_NAV_SECTIONS`],不得删除或重排这里的条目。
pub(super) const SECTION_IDS: [&str; 11] = [
"application",
@@ -40,7 +40,7 @@ pub(super) const SECTION_SEARCH_TERMS: [&str; 11] = [
"agents agent hook hooks 智能体 钩子 接入 claude codex opencode cursor kimi pi omp copilot grok",
];
pub(super) const HIDDEN_NAV_SECTIONS: &[usize] = &[3];
pub(super) const HIDDEN_NAV_SECTIONS: &[usize] = &[3, 9];
/// 保留原来的分组展开顺序,组名不再渲染;数组里仍保存稳定的 [`SECTION_IDS`]
/// 下标,不复制设置状态或路由。
@@ -186,9 +186,9 @@ mod tests {
assert_eq!(matching_sections(" FONT ", en), vec![1]);
assert_eq!(matching_sections("字体", zh), vec![1]);
assert_eq!(matching_sections("quick terminal", en), vec![7]);
assert_eq!(matching_sections("clod backup", en), vec![9]);
assert_eq!(matching_sections("webdav", en), vec![9]);
assert_eq!(matching_sections("123", zh), vec![9]);
assert!(matching_sections("clod backup", en).is_empty());
assert!(matching_sections("webdav", en).is_empty());
assert!(matching_sections("123", zh).is_empty());
assert_eq!(matching_sections("透度", zh).first(), Some(&1));
assert!(matching_sections("no such setting", en).is_empty());
assert_eq!(matching_sections("", en), visible_nav_sections().collect::<Vec<_>>());
@@ -0,0 +1,132 @@
//! 设置首页「项目与支持」下的赞助商页:左侧品牌贴图、右侧介绍与注册链接。
//!
//! 不做成外链行:外链行一点就跳浏览器,赞助商介绍在应用里就该读得到;
//! 页面只提供一条明确的注册链接,点它才离开应用。
use std::sync::{Arc, OnceLock};
use super::*;
/// 注册链接。`source` / `campaign` / `promo` 三个参数是赞助商给本项目的
/// 专属归因,README 与应用内必须用同一条,不能各写各的。
pub(super) const SPONSOR_URL: &str =
"https://fluxionai.space/register?source=github&campaign=pebrel&promo=pebrel";
const SPONSOR_NAME: &str = "Fluxion AI";
const LOGO_PNG: &[u8] = include_bytes!("../../../../extra/logo/sponsor_fluxionai.png");
/// 贴图自带白底圆角,深浅主题都读得清品牌字。
const LOGO_WIDTH: f32 = 188.0;
/// 贴图只解码一次:RGBA8 → BGRA(gpui 帧通道序),与壁纸 / 文档图同款。
fn sponsor_logo() -> Option<Arc<RenderImage>> {
static LOGO: OnceLock<Option<Arc<RenderImage>>> = OnceLock::new();
LOGO.get_or_init(|| {
let mut rgba = image::load_from_memory(LOGO_PNG).ok()?.into_rgba8();
for pixel in rgba.chunks_exact_mut(4) {
pixel.swap(0, 2);
}
Some(Arc::new(RenderImage::new([image::Frame::new(rgba)])))
})
.clone()
}
impl SettingsPane {
pub(super) fn open_sponsor_page(&mut self, cx: &mut Context<Self>) {
self.about_sponsor_open = true;
cx.notify();
}
pub(super) fn close_sponsor_page(&mut self, cx: &mut Context<Self>) {
self.about_sponsor_open = false;
cx.notify();
}
pub(super) fn section_sponsor(&mut self, cx: &mut Context<Self>) -> gpui::Div {
let language = crate::gpui_shell::config::ui_language(cx);
let theme = cx.theme();
let muted = theme.muted_foreground;
let ink = theme.foreground;
let link = theme.primary;
let hover = theme.list_hover;
let base_px = self.font_size_px(cx);
let back = h_flex()
.id("sponsor-back")
.w_auto()
.h(px(32.0))
.px_2()
.gap_2()
.items_center()
.rounded_md()
.cursor_pointer()
.text_color(muted)
.hover(move |row| row.bg(hover).text_color(ink))
.on_click(cx.listener(|this, _, _, cx| this.close_sponsor_page(cx)))
.child(Icon::new(IconName::ArrowLeft).small())
.child(language.pick("返回", "Back"));
let logo =
div().w(px(LOGO_WIDTH)).flex_shrink_0().when_some(sponsor_logo(), |slot, image| {
slot.child(gpui::StyledImage::object_fit(
img(image).w(px(LOGO_WIDTH)).h(px(LOGO_WIDTH * 276.0 / 376.0)),
gpui::ObjectFit::Contain,
))
});
let register = h_flex()
.id("sponsor-register")
.w_auto()
.mt(px(4.0))
.gap_1()
.items_center()
.cursor_pointer()
.text_color(link)
.hover(|row| row.underline())
.on_click(|_, _, cx| cx.open_url(SPONSOR_URL))
.child(language.pick(
"立即访问并注册,即可获得 $3 API 额度",
"Sign up now to receive $3 in API credit",
))
.child(Icon::new(IconName::ExternalLink).xsmall());
let copy = v_flex()
.flex_1()
.min_w_0()
.gap(px(10.0))
.child(
div()
.text_size(px(base_px * 1.3))
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(ink)
.child(SPONSOR_NAME),
)
.child(div().text_color(ink).child(language.pick(
"一个入口,接入并管理全球主流 AI 模型",
"One entry point to access and manage the world's leading AI models",
)))
.child(div().text_color(muted).line_height(px(base_px * 1.6)).child(language.pick(
"Fluxion AI 面向个人开发者、技术团队与企业,通过统一 API 接入并管理全球主流 AI 模型;\
通过多线路动态调度提升可用性,模型表现、响应时间与费用透明可查。\
根据不同模型与线路,API 调用成本较官方或基准价格可降低 40%—98%。",
"Fluxion AI serves individual developers, technical teams and enterprises with a \
unified API for accessing and managing mainstream AI models worldwide. Dynamic \
multi-route scheduling improves availability, and model performance, response \
time and cost stay transparent. Depending on the model and route, API calls can \
cost 40%–98% less than official or benchmark prices.",
)))
.child(register);
v_flex()
.w_full()
.gap(px(20.0))
.child(back)
.child(
div()
.h(px(30.0))
.text_size(px(base_px * 0.85))
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(muted)
.child(language.pick("赞助商", "Sponsors")),
)
.child(h_flex().w_full().items_start().gap(px(28.0)).child(logo).child(copy))
}
}
@@ -82,9 +82,12 @@ fn ai_toast_setting_is_searchable_and_has_a_visible_switch(cx: &mut gpui::TestAp
}
#[test]
fn settings_nav_visibility_keeps_stable_routes_and_restores_backup() {
fn settings_nav_visibility_keeps_stable_routes_and_hides_backup() {
let visibility: Vec<_> = (0..SECTION_IDS.len()).map(is_nav_section_visible).collect();
assert_eq!(visibility, vec![true, true, true, false, true, true, true, true, true, true, true]);
assert_eq!(
visibility,
vec![true, true, true, false, true, true, true, true, true, false, true]
);
assert_eq!(
SECTION_IDS,
[
@@ -106,14 +109,14 @@ fn settings_nav_visibility_keeps_stable_routes_and_restores_backup() {
#[test]
fn settings_nav_starts_with_application_then_frequent_options() {
let visible: Vec<_> = visible_nav_sections().collect();
assert_eq!(visible, vec![0, 1, 2, 10, 6, 7, 4, 5, 8, 9]);
assert_eq!(visible, vec![0, 1, 2, 10, 6, 7, 4, 5, 8]);
let zh_labels: Vec<_> = visible
.iter()
.map(|index| section_label(*index, crate::display::UiLanguage::ZhCn))
.collect();
assert_eq!(
zh_labels,
vec!["应用", "外观", "终端", "Agents", "交互", "按键映射", "SSH", "网络", "高级", "备份"]
vec!["应用", "外观", "终端", "Agents", "交互", "按键映射", "SSH", "网络", "高级"]
);
let en_labels: Vec<_> = visible
.iter()
@@ -131,7 +134,6 @@ fn settings_nav_starts_with_application_then_frequent_options() {
"SSH",
"Network",
"Advanced",
"Backup",
]
);
}
@@ -13,6 +13,54 @@ mod tests;
const HOST_CARD_HEIGHT: f32 = 68.0;
const HOST_CARD_EXTENT: f32 = HOST_CARD_HEIGHT + 8.0;
/// Center the glyph's ink, not its monospace advance or the surrounding text line.
fn host_icon(glyph: char, family: SharedString, color: gpui::Hsla) -> impl IntoElement {
gpui::canvas(
move |bounds, window, _| {
let font = gpui::font(family);
let text_system = window.text_system();
let font_id = text_system.resolve_font(&font);
let base_size = px(18.0);
let ink = text_system.typographic_bounds(font_id, base_size, glyph).ok();
let scale = ink
.filter(|ink| ink.size.width > px(0.0) && ink.size.height > px(0.0))
.map(|ink| 18.0 / f32::from(ink.size.width.max(ink.size.height)))
.unwrap_or(1.0);
let font_size = base_size * scale;
let text: SharedString = glyph.to_string().into();
let line = text_system.shape_line(
text.clone(),
font_size,
&[gpui::TextRun {
len: text.len(),
font,
color,
background_color: None,
underline: None,
strikethrough: None,
}],
None,
);
let height = line.ascent + line.descent;
let origin = if let Some(ink) = ink {
// Font coordinates are relative to the baseline with positive Y upwards.
let center = ink.center() * scale;
bounds.center() - gpui::point(center.x, line.ascent - center.y)
} else {
bounds.center() - gpui::point(line.width / 2.0, height / 2.0)
};
(line, origin, height)
},
|_, (line, origin, height), window, cx| {
if let Err(error) = line.paint(origin, height, gpui::TextAlign::Left, None, window, cx)
{
log::warn!("Unable to paint SSH host icon: {error}");
}
},
)
.size(px(22.0))
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(in crate::gpui_shell) enum HostScope {
All,
@@ -399,11 +447,7 @@ impl SettingsPane {
crate::gpui_shell::widgets::device_icon_container(cx)
.id(SharedString::from(format!("ssh-host-icon-{ix}")))
.debug_selector(move || format!("ssh-host-icon-{ix}"))
.font_family(symbol_family.clone())
.text_size(px(18.0))
.text_color(muted)
.text_center()
.child(os_icon.glyph.to_string()),
.child(host_icon(os_icon.glyph, symbol_family, muted)),
)
.child(
v_flex()
@@ -46,7 +46,10 @@ fn pairing_design_ssh_cards_keep_icon_anchors_and_compact_filter(cx: &mut gpui::
let icon = cx.debug_bounds("ssh-host-icon-0").expect("card anchor");
assert_eq!(icon.size, gpui::size(px(36.0), px(36.0)));
let first = cx.debug_bounds("ssh-host-row-0").unwrap();
assert_eq!(icon.center().y, first.center().y);
let next = cx.debug_bounds("ssh-host-row-1").unwrap();
let next_icon = cx.debug_bounds("ssh-host-icon-1").unwrap();
assert_eq!(icon.center().x, next_icon.center().x);
assert!(next.origin.y - first.bottom() >= px(8.0));
let filter = cx.debug_bounds("ssh-inline-filter").unwrap();
assert!(filter.size.width <= px(210.0));
-3
View File
@@ -841,7 +841,6 @@ pub struct NebulaWorkspace {
/// 系统关闭按钮可能连续送来多次 should-close;确认框在场时只保留一份。
window_close_confirm_open: bool,
window_close_pending: bool,
recovery_boot_attempts: u32,
/// `keep_session` 关窗后 HWND 已隐藏、PTY 仍在;托盘 / mux ATTACH 用来捞回。
window_hidden: bool,
/// 开窗时记下,mux `tab.new` 需要从 pump 拿到 `&mut Window`。
@@ -1073,7 +1072,6 @@ impl NebulaWorkspace {
spinner_visible: std::cell::Cell::new(false),
window_close_confirm_open: false,
window_close_pending: false,
recovery_boot_attempts: 0,
window_hidden: false,
window_handle: window.window_handle(),
runtime_window_id,
@@ -3114,7 +3112,6 @@ impl Render for NebulaWorkspace {
// 变化时重建,普通 render 不重复解码 PNG。
self.sidebar_logo_images = sidebar_logo_images(sidebar_logo_target_px);
self.sidebar_logo_target_px = sidebar_logo_target_px;
self.sync_settings_agent_logos(cx);
}
// Some tab-open/restore paths assign `active` directly. Clear a focus
// record tied to a different entity before deriving layout booleans.
@@ -53,6 +53,10 @@ fn width_limits_keep_room_for_the_document_without_losing_preference() {
fn file_path_edit_navigates_and_keeps_the_last_directory_on_error_or_escape(
cx: &mut TestAppContext,
) {
let select_all = match crate::platform::Platform::current() {
crate::platform::Platform::MacOS => "cmd-a",
_ => "ctrl-a",
};
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("sidebar.md");
let target = directory.path().join("中文 folder");
@@ -70,7 +74,7 @@ fn file_path_edit_navigates_and_keeps_the_last_directory_on_error_or_escape(
cx.update(|window, cx| window.draw(cx).clear(cx));
let path_button = cx.debug_bounds("file-tree-path").unwrap();
cx.simulate_click(path_button.center(), Modifiers::default());
cx.simulate_keystrokes("ctrl-a");
cx.simulate_keystrokes(select_all);
cx.simulate_input("中文 folder");
cx.simulate_keystrokes("enter");
cx.run_until_parked();
@@ -103,7 +107,7 @@ fn file_path_edit_navigates_and_keeps_the_last_directory_on_error_or_escape(
cx.update(|window, cx| window.draw(cx).clear(cx));
let path_button = cx.debug_bounds("file-tree-path").unwrap();
cx.simulate_click(path_button.center(), Modifiers::default());
cx.simulate_keystrokes("ctrl-a");
cx.simulate_keystrokes(select_all);
cx.simulate_input("missing-directory");
cx.simulate_keystrokes("enter");
cx.run_until_parked();
@@ -10,7 +10,6 @@ impl NebulaWorkspace {
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.recovery_boot_attempts = session.boot_attempts.saturating_add(1);
let mut restored = false;
for tab in &session.tabs {
restored |= self.restore_tab(tab, resume_ai, window, cx);
@@ -50,7 +49,6 @@ impl NebulaWorkspace {
}
let crashed = crate::session::was_crash(&session);
crate::session::mark_boot_attempt(&mut session);
self.recovery_boot_attempts = session.boot_attempts;
let mut restored = 0usize;
for tab in &session.tabs {
if self.restore_tab(tab, resume_ai, window, cx) {
@@ -154,18 +152,23 @@ impl NebulaWorkspace {
/// 当前工作区 → 共享 v4 快照。设置/文档/图片 tab 不进会话(旧壳同
/// 合同);AI 会话身份优先取 hook 直报的精确 id,退而取可解析的前台
/// 程序名(claude 无 id 恢复成 `--continue`,安全判定在 schema 层)。
///
/// 快照一律带 `boot_attempts = 0`:断路器只回答「这次启动活到了第一次
/// 自动保存没有」(session.rs 模块合同)。agent 恢复目标尚未被 hook /
/// 探针确认是另一回事——codex 停在 hooks 信任、目录选择或「已在别处
/// 打开」的提示上可以是几小时,正常退出照样算一次失败的话,三次就把
/// 整个工作区(含所有普通 tab)隔离掉了。未确认的目标本身仍随
/// `session_agent()` 落盘,下次启动照旧接续。
pub(crate) fn snapshot_session(&self, cx: &App) -> crate::session::Session {
use crate::session::{AgentSession, LaunchSession, Session, TabSession};
let mut tabs = Vec::new();
let mut recovery_pending = false;
let mut active_out = 0usize;
for (ix, tab) in self.tabs.iter().enumerate() {
let WorkspaceTab::Terminal { panes, tree, focused, .. } = tab else { continue };
if ix == self.active {
active_out = tabs.len();
}
recovery_pending |= panes.iter().any(|pane| pane.view.read(cx).recovery_pending());
let leaf_data = |id: u64| -> (String, Option<AgentSession>, Option<LaunchSession>) {
let Some(pane) = panes.iter().find(|pane| pane.id == id) else {
return (String::new(), None, None);
@@ -202,10 +205,6 @@ impl NebulaWorkspace {
active_pane,
});
}
let mut session = Session::new(active_out, tabs);
if recovery_pending {
session.boot_attempts = self.recovery_boot_attempts;
}
session
Session::new(active_out, tabs)
}
}
@@ -36,7 +36,6 @@ impl NebulaWorkspace {
let view = cx.new(|cx| SettingsPane::new(window, cx));
let subscription = cx.subscribe_in(&view, window, Self::on_settings_event);
self.settings_surface = Some((view, subscription));
self.sync_settings_agent_logos(cx);
}
self.settings_tab_open = true;
@@ -70,15 +69,6 @@ impl NebulaWorkspace {
}
}
pub(super) fn sync_settings_agent_logos(&self, cx: &mut Context<Self>) {
if let Some((view, _)) = self.settings_surface.as_ref() {
view.update(cx, |pane, cx| {
pane.set_agent_logos(&self.sidebar_logo_images);
cx.notify();
});
}
}
pub(super) fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.settings_open {
self.close_settings(window, cx);
@@ -805,3 +805,91 @@ fn sidebar_resize_hot_zone_follows_the_theme_boundary() {
// 热区宽度足以盖住线:线心两侧各 3px。
assert!(SIDEBAR_RESIZE_HANDLE_WIDTH * 0.5 > nord.divider);
}
/// 回归:冷恢复的 codex 目标停在 hooks 信任 / 目录选择 / 「已在别处打开」
/// 提示上时,hook 报不了、探针拿不到 rollout,pane 一直是「未确认」。此前
/// 快照把 `boot_attempts` 钉在启动时的 +1 值,1 Hz 自动保存永不归零——
/// 三次正常退出后整个工作区被挪去 `session.crashed.json`。断路器只回答
/// 「这次启动活到了第一次自动保存没有」,未确认的目标只随快照落盘、不计数。
#[cfg(feature = "gpui-test-support")]
mod boot_breaker_tests {
use super::*;
use crate::session::{AgentSession, LaunchSession, LayoutSession, TabSession};
use gpui::TestAppContext;
#[gpui::test]
fn unconfirmed_agent_target_does_not_pin_the_boot_breaker(cx: &mut TestAppContext) {
cx.update(|cx| {
gpui_component::init(cx);
crate::gpui_shell::math_view::register(cx);
crate::gpui_shell::file_editor::init(cx);
super::super::init(cx);
cx.set_reduce_motion(true);
windowing::initialize(cx, crate::runtime_api::RuntimeHub::new());
});
let mut workspace = None;
let (_, mut window) = cx.add_window_view(|window, cx| {
let view = cx.new(|cx| {
NebulaWorkspace::new(
window,
None,
None,
1,
crate::runtime_api::RuntimeHub::new(),
windowing::WorkspaceStartup::Empty,
windowing::WindowRole::Regular,
cx,
)
});
workspace = Some(view.clone());
Root::new(view, window, cx)
});
let workspace = workspace.unwrap();
let saved = AgentSession {
source: "codex".into(),
session_id: Some("saved-42".into()),
session_file: None,
};
let launch = LaunchSession::Shell {
name: "missing".into(),
program: "pebrel-test-missing-shell-executable".into(),
args: vec![],
};
let tab = TabSession {
cwd: String::new(),
custom_name: None,
color: None,
launch: Some(launch.clone()),
layout: Some(LayoutSession::Pane {
cwd: String::new(),
agent: Some(saved.clone()),
launch: Some(launch),
}),
active_pane: 0,
};
// 第三次启动:磁盘上的计数已被 `mark_boot_attempt` 抬到 2。
let mut session = crate::session::Session::new(0, vec![tab]);
session.boot_attempts = 2;
window.update(|window, cx| {
workspace.update(cx, |workspace, cx| {
assert!(workspace.restore_update_session(&session, true, window, cx));
let pane = workspace.tabs.last().and_then(|tab| tab.focused_view()).unwrap();
assert!(pane.read(cx).recovery_pending(), "codex 目标尚未被 hook / 探针确认");
let snapshot = workspace.snapshot_session(cx);
assert_eq!(snapshot.boot_attempts, 0, "未确认的恢复目标不得钉住断路器计数");
assert!(
crate::session::should_restore(&snapshot),
"首次自动保存即让下次启动照常恢复"
);
let Some(LayoutSession::Pane { agent, .. }) =
snapshot.tabs.last().and_then(|tab| tab.layout.as_ref())
else {
panic!("restored tab must snapshot as a single pane");
};
assert_eq!(agent.as_ref(), Some(&saved), "恢复目标本身仍随快照落盘,下次照旧接续");
})
});
}
}
@@ -189,7 +189,8 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let grok_dir = dir.path().join(".grok/bin");
let grok = executable(&grok_dir, "agent");
let cursor_dir = dir.path().join("cursor-agent/versions/current");
// Keep the installation directory distinct from the extensionless PATH command on Unix.
let cursor_dir = dir.path().join("installed/cursor-agent/versions/current");
let cursor = executable(&cursor_dir, "agent");
let dirs = vec![grok_dir, cursor_dir];
assert_eq!(find_executable(AgentKind::Cursor, &dirs), Some(cursor));
+36 -4
View File
@@ -426,13 +426,23 @@ where
Ok(got) => {
// Startup profiling: the process-wide first PTY output ≈
// the console host finished its bring-up handshake and
// the shell started talking.
// the shell started talking. The first chunks are dumped
// escaped so a silent boot can be aligned with the host's
// handshake byte for byte.
{
use std::sync::atomic::{AtomicBool, Ordering};
static FIRST_BYTES: AtomicBool = AtomicBool::new(false);
if !FIRST_BYTES.swap(true, Ordering::Relaxed) {
use std::sync::atomic::{AtomicUsize, Ordering};
static CHUNKS: AtomicUsize = AtomicUsize::new(0);
let index = CHUNKS.fetch_add(1, Ordering::Relaxed);
if index == 0 {
crate::pty_trace("first conout bytes");
}
if index < 12 {
let shown = &buf[unprocessed..unprocessed + got.min(200)];
crate::pty_trace(&format!(
"conout chunk {index} ({got} bytes): {}",
shown.escape_ascii()
));
}
}
unprocessed += got;
},
@@ -480,6 +490,16 @@ where
self.event_proxy.send_event(Event::Wakeup);
}
// Boot profiling: a readable wake that carried no bytes is the
// signature of a lost or spurious wakeup; report only the first few.
if processed == 0 {
use std::sync::atomic::{AtomicUsize, Ordering};
static EMPTY_READS: AtomicUsize = AtomicUsize::new(0);
if EMPTY_READS.fetch_add(1, Ordering::Relaxed) < 8 {
crate::pty_trace("pty_read: readable wake with no bytes");
}
}
Ok(processed)
}
@@ -495,6 +515,18 @@ where
break 'write_many;
},
Ok(n) => {
{
use std::sync::atomic::{AtomicUsize, Ordering};
static WRITES: AtomicUsize = AtomicUsize::new(0);
let index = WRITES.fetch_add(1, Ordering::Relaxed);
if index < 12 {
let shown = &current.remaining_bytes()[..n.min(200)];
crate::pty_trace(&format!(
"conin write {index} ({n} bytes): {}",
shown.escape_ascii()
));
}
}
current.advance(n);
if current.finished() {
state.goto_next();
+6 -1
View File
@@ -70,6 +70,7 @@ impl<R: Read + Send + 'static> UnblockedReader<R> {
Poll::Ready(Ok(0)) => {
// Either the pipe is closed or the reader is at its EOF.
// In any case, we are done.
crate::pty_trace("conout reader thread: EOF");
return;
},
@@ -84,10 +85,14 @@ impl<R: Read + Send + 'static> UnblockedReader<R> {
},
// Windows reports normal anonymous-pipe EOF this way.
Poll::Ready(Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => return,
Poll::Ready(Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => {
crate::pty_trace("conout reader thread: broken pipe");
return;
},
Poll::Ready(Err(e)) => {
log::error!("error writing to pipe: {}", e);
crate::pty_trace("conout reader thread: read error");
return;
},
+1 -1
View File
@@ -36,7 +36,7 @@
"cases.split.status": "passed",
"cases.ssh_loop.reason": "runtime_api_has_no_ssh_open_method",
"cases.ssh_loop.status": "skipped",
"platform": "windows-x86_64",
"platform": "windows",
"schema_version": 1,
"summary.failed": 0,
"summary.passed": 9,
+4 -1
View File
@@ -936,7 +936,10 @@ def load_rules(golden_dir: Path) -> dict[str, Any]:
def stable_flat(report: dict[str, Any], golden_dir: Path) -> dict[str, Any]:
rules = load_rules(golden_dir)
return filter_flat(flatten(report), list(rules.get("volatile", {})))
flat = filter_flat(flatten(report), list(rules.get("volatile", {})))
# One golden per family serves every architecture of that platform.
flat["platform"] = platform_family(str(report["platform"]))
return flat
def compare_platform_golden(report: dict[str, Any], golden_dir: Path) -> list[str]:
+24
View File
@@ -154,6 +154,30 @@ class ComparisonTests(unittest.TestCase):
errors = compare_reports([first, second], golden)
self.assertTrue(any("tail_contains_marker" in error for error in errors))
def test_platform_golden_serves_every_architecture_of_a_family(self) -> None:
from conformance.harness import compare_platform_golden, stable_flat
report = {
"schema_version": 1,
"platform": "windows-x86_64",
"cases": {"boot": {"status": "passed", "duration_ms": 3}},
"summary": {"total": 1, "passed": 1, "failed": 0, "skipped": 0},
}
with tempfile.TemporaryDirectory() as directory:
golden = Path(directory)
(golden / "whitelist.json").write_text(
json.dumps({"volatile": {"cases.*.duration_ms": "timing"}, "cross_platform": {}}),
encoding="utf-8",
)
expected = stable_flat(report, golden)
self.assertEqual(expected["platform"], "windows")
(golden / "windows.json").write_text(json.dumps(expected), encoding="utf-8")
arm64 = json.loads(json.dumps(report))
arm64["platform"] = "windows-aarch64"
self.assertEqual(compare_platform_golden(arm64, golden), [])
arm64["cases"]["boot"]["status"] = "failed"
self.assertTrue(compare_platform_golden(arm64, golden))
class ArchiveSafetyTests(unittest.TestCase):
def test_new_portable_package_resolves_only_the_product_executable(self) -> None:
@@ -0,0 +1,24 @@
# Work around the windows-11-arm runner image: Windows PowerShell 5.1 there
# renders nothing (not even `Write-Output`) when its PSModulePath is the
# registry value alone, and recovers as soon as the PowerShell 7 module
# directories that the runner's pwsh step shell injects come first. Pebrel
# rebuilds child environments from the registry (the stock console host rule), so
# the injected entries never reach the default tab; putting them into the
# user-level registry value keeps the rebuild faithful and PS 5.1 alive.
# Evidence: environment-block replay and PSModulePath bisect on the ARM64
# runner, 2026-09-22.
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$pwsh7 = @(
(Join-Path $HOME 'Documents\PowerShell\Modules'),
(Join-Path $env:ProgramFiles 'PowerShell\Modules'),
(Join-Path $env:ProgramFiles 'PowerShell\7\Modules')
)
$machine = [Environment]::GetEnvironmentVariable('PSModulePath', 'Machine')
$user = [Environment]::GetEnvironmentVariable('PSModulePath', 'User')
$entries = @($pwsh7) + @(($user, $machine) -join ';' -split ';' | Where-Object { $_ })
$value = ($entries | Select-Object -Unique) -join ';'
[Environment]::SetEnvironmentVariable('PSModulePath', $value, 'User')
"User PSModulePath: $value"
+7 -2
View File
@@ -9,6 +9,9 @@ param(
[ValidateSet('NebulaTerminal', 'Pebrel')]
[string] $PackageBrand = 'Pebrel',
[ValidateSet('x64', 'arm64')]
[string] $Architecture = 'x64',
[switch] $SkipBuild,
# 与 -SkipBuild 联用:跳过「exe 必须比源码新」的陈旧检查。仅用于脚本
# 自测;发布产物一律走全新构建。
@@ -45,8 +48,8 @@ $cargoTargetRoot = [System.IO.Path]::GetFullPath($TargetDirectory)
$outputRoot = [System.IO.Path]::GetFullPath($OutputDirectory)
$targetRoot = Join-Path $cargoTargetRoot $Configuration
$stage = Join-Path $outputRoot ".stage-$Version-$PID"
$zipPath = Join-Path $outputRoot "$PackageBrand-v$Version-windows-x64.zip"
$temporaryZip = Join-Path $outputRoot ".$PackageBrand-v$Version-windows-x64-$PID.tmp.zip"
$zipPath = Join-Path $outputRoot "$PackageBrand-v$Version-windows-$Architecture.zip"
$temporaryZip = Join-Path $outputRoot ".$PackageBrand-v$Version-windows-$Architecture-$PID.tmp.zip"
$manifest = [ordered]@{
'pebrel.exe' = Join-Path $targetRoot 'pebrel.exe'
@@ -155,6 +158,8 @@ if ($missing.Count -ne 0) {
}
$packagedExe = $manifest['pebrel.exe']
. (Join-Path $PSScriptRoot 'windows-package-architecture.ps1')
Assert-WindowsPackageArchitecture -Root $targetRoot -Architecture $Architecture
Assert-FreshBinaries
if ($PackageBrand -eq 'Pebrel' -and (Get-Item -LiteralPath $packagedExe).VersionInfo.ProductName -ne 'Pebrel') {
throw 'Pebrel packages require a freshly built Pebrel executable, not renamed Nebula binaries.'
+5 -2
View File
@@ -47,7 +47,7 @@ def verify_binary_freshness(binary: Path, repo: Path) -> None:
raise ManifestError(f"binary is older than {stale.relative_to(repo)}; rebuild GPUI before packaging")
def validate_evidence(directory: Path, commit: str) -> None:
def validate_evidence(directory: Path, commit: str, *, windows_arm64: bool = False) -> None:
root = str(Path(__file__).resolve().parents[1])
if root not in sys.path:
sys.path.insert(0, root)
@@ -55,7 +55,10 @@ def validate_evidence(directory: Path, commit: str) -> None:
golden = Path(__file__).resolve().parent / "conformance/golden"
reports = []
for name, platform in RUNTIME_REPORTS.items():
required_reports = dict(RUNTIME_REPORTS)
if windows_arm64:
required_reports["windows-arm64-report.json"] = "windows-aarch64"
for name, platform in required_reports.items():
report = json.loads((directory / name).read_text(encoding="utf-8"))
if report.get("platform") != platform or report.get("build", {}).get("commit") != commit:
raise ManifestError(f"wrong platform or source commit in {name}")
+2
View File
@@ -43,6 +43,8 @@ def expected_asset_names(version: str) -> tuple[str, ...]:
f"Pebrel-v{version}-windows-x64.zip",
f"Pebrel-v{version}-windows-x64-setup.exe",
)
if tuple(map(int, version.split("."))) >= (1, 9, 0):
names += (f"Pebrel-v{version}-windows-arm64.zip",)
# The old-name installer was retired from 1.7.0; retain historical manifests.
if tuple(map(int, version.split("."))) < (1, 7, 0):
names += (f"NebulaTerminal-{version}-windows-x64-setup.exe",)
+1
View File
@@ -42,6 +42,7 @@ try {
throw 'Release packaging must use the shared explicit product build.'
}
& (Join-Path $PSScriptRoot 'build-windows-product.tests.ps1')
& (Join-Path $PSScriptRoot 'windows-package-architecture.tests.ps1')
if ($scriptBody -notmatch 'Assert-FreshBinaries') {
throw 'Release packaging must refuse stale binaries (freshness guard missing).'
+10
View File
@@ -202,6 +202,16 @@ class PreviewReleaseTests(unittest.TestCase):
"screenshot": "captured", "rendered_text": True,
}), encoding="utf-8")
validate_evidence(root, "a" * 40)
with self.assertRaises(FileNotFoundError):
validate_evidence(root, "a" * 40, windows_arm64=True)
arm_report = root / "windows-arm64-report.json"
report["platform"] = "windows-aarch64"
arm_report.write_text(json.dumps(report), encoding="utf-8")
validate_evidence(root, "a" * 40, windows_arm64=True)
report["platform"] = "windows-x86_64"
arm_report.write_text(json.dumps(report), encoding="utf-8")
with self.assertRaisesRegex(ManifestError, "wrong platform"):
validate_evidence(root, "a" * 40, windows_arm64=True)
with self.assertRaisesRegex(ManifestError, "source commit"):
validate_evidence(root, "b" * 40)
launch_path = root / "macos-aarch64-launch.json"
+30 -7
View File
@@ -68,22 +68,33 @@ def notes(checksum_placeholder: bool = True) -> str:
class StableReleaseTests(unittest.TestCase):
def test_stable_workflow_requires_full_native_tests_before_aggregation(self) -> None:
def test_stable_workflow_packages_without_repeating_native_tests(self) -> None:
# The Full native tests workflow already covers every PR, merge group
# and main push; the release run only packages and verifies the runtime
# conformance evidence of each package, so its wall time is bounded by
# the slowest build rather than by test scheduling.
root = Path(__file__).resolve().parents[2]
workflow = (root / ".github/workflows/release.yml").read_text(encoding="utf-8")
native = workflow.split(" native-tests:\n", 1)[1].split("\n linux:\n", 1)[0]
self.assertIn("uses: ./.github/workflows/linux-lua.yml", native)
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)
self.assertIn("workflow_call:", shared)
for trigger in ("pull_request:", "merge_group:", "branches: [main]"):
self.assertIn(trigger, shared)
self.assertIn("run: python scripts/ci_native_tests.py", shared)
self.assertIn("cargo check --locked --workspace --release", shared)
self.assertIn("tools/i18n-contract/Cargo.toml", shared)
self.assertNotIn("continue-on-error", shared)
self.assertNotIn("continue-on-error", native)
aggregate = workflow.split("\n aggregate:\n", 1)[1].split("\n publish:\n", 1)[0]
self.assertIn("needs: [prepare, native-tests, linux, macos, windows]", aggregate)
self.assertIn("needs: [prepare, linux, macos, windows, windows-arm64]", aggregate)
self.assertIn("windows_arm64=True", aggregate)
arm = workflow.split("\n windows-arm64:\n", 1)[1].split("\n aggregate:\n", 1)[0]
for required in ("runs-on: windows-11-arm", "host: aarch64-pc-windows-msvc",
"-Architecture arm64", "windows-arm64-report.json",
"scripts/build-windows-product.ps1", "--platform windows-aarch64"):
self.assertIn(required, arm)
self.assertNotIn("continue-on-error", arm)
self.assertNotIn("always()", aggregate)
def test_native_packagers_expose_stable_channel_without_preview_id(self) -> None:
@@ -115,10 +126,22 @@ class StableReleaseTests(unittest.TestCase):
for version in ("1.7.0", "1.7.1", "1.10.0", "2.0.0"):
with self.subTest(version=version):
names = expected_asset_names(version)
self.assertEqual(len(names), 7)
self.assertEqual(len(names), 8 if tuple(map(int, version.split('.'))) >= (1, 9, 0) else 7)
self.assertIn(f"Pebrel-v{version}-windows-x64-setup.exe", names)
self.assertNotIn(f"NebulaTerminal-{version}-windows-x64-setup.exe", names)
def test_19_requires_native_windows_arm64_without_changing_historical_assets(self) -> None:
self.assertNotIn("Pebrel-v1.8.2-windows-arm64.zip", expected_asset_names("1.8.2"))
version = "1.9.0"
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
for name in expected_asset_names(version):
write_fake_asset(root / name)
self.assertEqual(len(validate_assets(root, version)), 8)
(root / "Pebrel-v1.9.0-windows-arm64.zip").unlink()
with self.assertRaisesRegex(StableReleaseError, "missing: Pebrel-v1.9.0-windows-arm64.zip"):
validate_assets(root, version)
def test_17_assets_reject_retired_alias_and_missing_installer(self) -> None:
version = "1.7.0"
with tempfile.TemporaryDirectory() as directory:
@@ -0,0 +1,42 @@
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot '../windows-package-architecture.ps1')
$fixtureRoot = Join-Path ([IO.Path]::GetTempPath()) ("pebrel-package-architecture-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $fixtureRoot | Out-Null
try {
foreach ($architecture in @('x64', 'arm64')) {
$bytes = New-Object byte[] 96
[BitConverter]::GetBytes([uint16]0x5A4D).CopyTo($bytes, 0)
[BitConverter]::GetBytes([uint32]64).CopyTo($bytes, 0x3C)
[BitConverter]::GetBytes([uint32]0x4550).CopyTo($bytes, 64)
$machine = if ($architecture -eq 'arm64') { 0xAA64 } else { 0x8664 }
[BitConverter]::GetBytes([uint16]$machine).CopyTo($bytes, 68)
foreach ($name in @('pebrel.exe', 'pebrel-hook.exe', 'conpty.dll', 'OpenConsole.exe')) {
[IO.File]::WriteAllBytes((Join-Path $fixtureRoot $name), $bytes)
}
Assert-WindowsPackageArchitecture -Root $fixtureRoot -Architecture $architecture
$wrong = if ($architecture -eq 'arm64') { 'x64' } else { 'arm64' }
$rejected = $false
try { Assert-WindowsPackageArchitecture -Root $fixtureRoot -Architecture $wrong } catch {
$rejected = $_.Exception.Message -match 'architecture does not match'
}
if (-not $rejected) { throw 'Mislabeled machine type was accepted' }
# The application alone is insufficient: the packaged hook and console runtime must match.
$bytes[68] = 0
[IO.File]::WriteAllBytes((Join-Path $fixtureRoot 'pebrel-hook.exe'), $bytes)
$rejected = $false
try { Assert-WindowsPackageArchitecture -Root $fixtureRoot -Architecture $architecture } catch {
$rejected = $_.Exception.Message -match 'pebrel-hook.exe'
}
if (-not $rejected) { throw 'Mismatched hook helper was accepted' }
[IO.File]::WriteAllBytes((Join-Path $fixtureRoot 'pebrel.exe'), [byte[]]@(0, 1, 2))
$rejected = $false
try { Assert-WindowsPackageArchitecture -Root $fixtureRoot -Architecture $architecture } catch {
$rejected = $_.Exception.Message -match 'Invalid executable header'
}
if (-not $rejected) { throw 'Truncated executable was accepted' }
}
Write-Output 'windows-package-architecture.tests.ps1: PASS'
} finally {
Remove-Item -LiteralPath $fixtureRoot -Recurse -Force
}
+30
View File
@@ -0,0 +1,30 @@
# Validate the machine type of every executable payload before naming a Windows package.
function Assert-WindowsPackageArchitecture {
param(
[Parameter(Mandatory = $true)][string] $Root,
[ValidateSet('x64', 'arm64')][string] $Architecture = 'x64'
)
$machine = if ($Architecture -eq 'arm64') { 0xAA64 } else { 0x8664 }
foreach ($name in @('pebrel.exe', 'pebrel-hook.exe', 'conpty.dll', 'OpenConsole.exe')) {
$path = Join-Path $Root $name
$stream = [IO.File]::OpenRead($path)
$reader = [IO.BinaryReader]::new($stream)
try {
if ($stream.Length -lt 64 -or $reader.ReadUInt16() -ne 0x5A4D) {
throw "Invalid executable header: $path"
}
$stream.Position = 0x3C
$offset = $reader.ReadUInt32()
if ($offset -lt 64 -or $offset -gt $stream.Length - 6) {
throw "Invalid executable header offset: $path"
}
$stream.Position = $offset
if ($reader.ReadUInt32() -ne 0x4550 -or $reader.ReadUInt16() -ne $machine) {
throw "Executable architecture does not match $Architecture`: $path"
}
} finally {
$reader.Dispose()
$stream.Dispose()
}
}
}