diff --git a/.github/workflows/ui-review-screenshots.yml b/.github/workflows/ui-review-screenshots.yml
new file mode 100644
index 00000000..d3df8c38
--- /dev/null
+++ b/.github/workflows/ui-review-screenshots.yml
@@ -0,0 +1,496 @@
+name: UI review screenshots
+
+on:
+ workflow_dispatch:
+ push:
+ branches: ["codex/macos-system-menu"]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ui-review-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ CARGO_TERM_COLOR: always
+ RUST_BACKTRACE: "1"
+ RUST_TOOLCHAIN: "1.97.1"
+ CARGO_TARGET_DIR: target/ui-review
+ UI_REVIEW_PR: "288"
+ UI_REVIEW_SCENARIO: "menu"
+ UI_REVIEW_TARGET: "macOS native Pebrel application menu and menu commands"
+
+jobs:
+ native-ui:
+ name: ${{ matrix.name }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: Windows UI review
+ runner: windows-2022
+ platform: windows
+ - name: macOS UI review
+ runner: macos-26
+ platform: macos
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 75
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: ${{ env.RUST_TOOLCHAIN }}
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ cache-on-failure: true
+ workspaces: ". -> target/ui-review"
+
+ - name: Prepare Windows console runtime
+ if: matrix.platform == 'windows'
+ shell: pwsh
+ run: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost
+
+ - name: Build complete Pebrel application
+ run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell
+
+ - name: Capture Windows application and changed UI
+ if: matrix.platform == 'windows'
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $qa = Join-Path $env:RUNNER_TEMP 'ui-review'
+ $config = Join-Path $qa 'config'
+ New-Item -ItemType Directory -Force -Path $qa, $config | Out-Null
+ @(
+ 'language=en-US'
+ 'theme=Nord'
+ 'opacity=1'
+ 'blur=off'
+ 'restore_session=false'
+ 'resume_ai=false'
+ 'auto_check_updates=off'
+ ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii
+
+ $env:PEBREL_CONFIG_DIR = $config
+ $app = Join-Path $PWD 'target/ui-review/release/pebrel.exe'
+ $stdout = Join-Path $qa 'pebrel.stdout.log'
+ $stderr = Join-Path $qa 'pebrel.stderr.log'
+ $p = Start-Process $app -ArgumentList '--working-directory', $PWD -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr
+ Start-Sleep -Seconds 7
+
+ function Capture([string]$name) {
+ $shot = Join-Path $qa $name
+ for ($i = 0; $i -lt 8; $i++) {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -Shot $shot
+ if (Test-Path $shot) { return }
+ Start-Sleep -Seconds 1
+ }
+ throw "Could not capture Pebrel window: $name"
+ }
+
+ Capture '00-full-app.png'
+
+ switch ($env:UI_REVIEW_SCENARIO) {
+ 'settings' {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,'
+ Capture '01-settings-controls.png'
+ }
+ 'shortcuts' {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,'
+ Capture '01-shortcut-labels.png'
+ }
+ 'dialog' {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,'
+ Capture '01-settings-shortcuts.png'
+ }
+ 'sidebar' {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,'
+ Capture '01-sidebar-settings.png'
+ }
+ 'ssh-copy' {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -TypeText '^,'
+ Capture '01-ssh-settings-entry.png'
+ }
+ 'ssh-ports' {
+ Capture '01-ssh-ports-regression.png'
+ }
+ default {
+ Capture '01-platform-regression.png'
+ }
+ }
+
+ @(
+ "scenario=$env:UI_REVIEW_SCENARIO"
+ "target=$env:UI_REVIEW_TARGET"
+ "platform=windows"
+ "pid=$($p.Id)"
+ ) | Set-Content -Path (Join-Path $qa 'review-target.txt') -Encoding utf8
+
+ if (Get-Process -Id $p.Id -ErrorAction SilentlyContinue) {
+ & ./scripts/ui_probe.ps1 -ProcId $p.Id -Kill
+ }
+
+ - name: Capture SSH copy menu on Windows
+ if: matrix.platform == 'windows' && env.UI_REVIEW_SCENARIO == 'ssh-copy'
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $qa = Join-Path $env:RUNNER_TEMP 'ui-review'
+ $probe = Join-Path $qa 'ssh-copy-probe'
+ $config = Join-Path $probe 'config'
+ New-Item -ItemType Directory -Force -Path $probe, $config | Out-Null
+ @(
+ 'language=en-US'
+ 'theme=Nord'
+ 'opacity=1'
+ 'blur=off'
+ 'restore_session=false'
+ 'resume_ai=false'
+ 'auto_check_updates=off'
+ ) | Set-Content -Path (Join-Path $config 'pebrel_settings.txt') -Encoding ascii
+
+ $env:PEBREL_CONFIG_DIR = $config
+ $env:PEBREL_SSH_COPY_QA_DIR = $probe
+ $stdout = Join-Path $probe 'probe.stdout.log'
+ $stderr = Join-Path $probe 'probe.stderr.log'
+ $args = @('test', '--locked', '-p', 'nebula', '--bin', 'pebrel', '--features', 'gpui-test-support', 'native_ssh_copy_context_menu_preview', '--', '--ignored', '--nocapture')
+ $cargo = Start-Process cargo -ArgumentList $args -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr
+
+ $ready = Join-Path $probe 'menu-ready.json'
+ $deadline = (Get-Date).AddMinutes(20)
+ while (-not (Test-Path $ready)) {
+ if ($cargo.HasExited) {
+ Get-Content $stdout -ErrorAction SilentlyContinue
+ Get-Content $stderr -ErrorAction SilentlyContinue
+ throw 'SSH screenshot probe exited before opening its native window.'
+ }
+ if ((Get-Date) -gt $deadline) { throw 'Timed out waiting for SSH screenshot probe.' }
+ Start-Sleep -Milliseconds 500
+ }
+
+ $probePid = (Get-Content $ready -Raw | ConvertFrom-Json).pid
+ ./scripts/ui_probe.ps1 -ProcId $probePid -RightClick '520,220' -Shot (Join-Path $qa '02-ssh-context-menu.png')
+ ./scripts/ui_probe.ps1 -ProcId $probePid -Click '550,294'
+ Start-Sleep -Milliseconds 700
+ ./scripts/ui_probe.ps1 -ProcId $probePid -Shot (Join-Path $qa '03-ssh-copy-editor.png')
+ New-Item -ItemType File -Force -Path (Join-Path $probe 'capture-complete') | Out-Null
+ $cargo.WaitForExit()
+ if ($cargo.ExitCode -ne 0) {
+ Get-Content $stdout -ErrorAction SilentlyContinue
+ Get-Content $stderr -ErrorAction SilentlyContinue
+ throw "SSH screenshot probe failed with exit code $($cargo.ExitCode)."
+ }
+
+ - name: Capture macOS application and changed UI
+ if: matrix.platform == 'macos'
+ shell: bash
+ run: |
+ set -euo pipefail
+ qa="$RUNNER_TEMP/ui-review"
+ config="$qa/config"
+ mkdir -p "$qa" "$config"
+ cat >"$config/pebrel_settings.txt" <<'EOF'
+ language=en-US
+ theme=Nord
+ opacity=1
+ blur=off
+ restore_session=false
+ resume_ai=false
+ auto_check_updates=off
+ EOF
+
+ export PEBREL_CONFIG_DIR="$config"
+ app="$PWD/target/ui-review/release/pebrel"
+ "$app" --working-directory "$PWD" >"$qa/pebrel.stdout.log" 2>"$qa/pebrel.stderr.log" &
+ pid=$!
+
+ cleanup() {
+ kill "$pid" >/dev/null 2>&1 || true
+ wait "$pid" >/dev/null 2>&1 || true
+ }
+ trap cleanup EXIT
+
+ for _ in $(seq 1 20); do
+ if ! kill -0 "$pid" >/dev/null 2>&1; then
+ cat "$qa/pebrel.stderr.log" >&2 || true
+ exit 1
+ fi
+ sleep 0.5
+ done
+
+ screencapture -x "$qa/00-full-app.png"
+
+ focus_and_keys() {
+ local keys="$1"
+ PID="$pid" KEYS="$keys" osascript <<'APPLESCRIPT' || true
+ set targetPid to (system attribute "PID") as integer
+ set requestedKeys to system attribute "KEYS"
+ tell application "System Events"
+ set appProc to first application process whose unix id is targetPid
+ set frontmost of appProc to true
+ delay 0.4
+ if requestedKeys is "settings" then
+ keystroke "," using command down
+ else if requestedKeys is "menu" then
+ click menu bar item 2 of menu bar 1 of appProc
+ else if requestedKeys is "quit" then
+ keystroke "q" using command down
+ end if
+ end tell
+ APPLESCRIPT
+ }
+
+ case "$UI_REVIEW_SCENARIO" in
+ settings)
+ focus_and_keys settings
+ sleep 2
+ screencapture -x "$qa/01-settings-controls.png"
+ ;;
+ shortcuts)
+ focus_and_keys settings
+ sleep 2
+ screencapture -x "$qa/01-shortcut-labels.png"
+ ;;
+ dialog)
+ focus_and_keys settings
+ sleep 2
+ screencapture -x "$qa/01-settings-shortcuts.png"
+ focus_and_keys quit
+ sleep 1
+ screencapture -x "$qa/02-dialog-state.png" || true
+ ;;
+ sidebar)
+ focus_and_keys settings
+ sleep 2
+ screencapture -x "$qa/01-sidebar-settings.png"
+ ;;
+ menu)
+ focus_and_keys menu
+ sleep 1
+ screencapture -x "$qa/01-native-menu.png"
+ ;;
+ portable)
+ cleanup
+ trap - EXIT
+ bundle="$RUNNER_TEMP/Pebrel UI Review.app"
+ mkdir -p "$bundle/Contents/MacOS"
+ cp "$app" "$bundle/Contents/MacOS/pebrel"
+ chmod +x "$bundle/Contents/MacOS/pebrel"
+ cat >"$bundle/Contents/Info.plist" <<'PLIST'
+
+
+
+ CFBundleExecutablepebrel
+ CFBundleIdentifierio.github.kuddev.pebrel.ui-review
+ CFBundleNamePebrel UI Review
+ CFBundlePackageTypeAPPL
+
+ PLIST
+ unset PEBREL_CONFIG_DIR NEBULA_CONFIG_DIR PEBREL_CONFIG_FILE NEBULA_CONFIG_FILE PEBREL_GPUI_CONFIG NEBULA_GPUI_CONFIG
+ export HOME="$qa/home"
+ mkdir -p "$HOME"
+ open -n "$bundle"
+ sleep 4
+ screencapture -x "$qa/01-portable-startup-dialog.png"
+ pkill -f "$bundle/Contents/MacOS/pebrel" || true
+ ;;
+ ssh-copy)
+ focus_and_keys settings
+ sleep 2
+ screencapture -x "$qa/01-ssh-settings-entry.png"
+ ;;
+ ssh-ports)
+ screencapture -x "$qa/01-ssh-ports-regression.png"
+ ;;
+ notification)
+ screencapture -x "$qa/01-foreground-notification-regression.png"
+ ;;
+ *)
+ screencapture -x "$qa/01-platform-regression.png"
+ ;;
+ esac
+
+ {
+ echo "scenario=$UI_REVIEW_SCENARIO"
+ echo "target=$UI_REVIEW_TARGET"
+ echo "platform=macos"
+ sw_vers
+ } >"$qa/review-target.txt"
+
+ - name: Upload reviewer screenshots
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ui-review-${{ matrix.platform }}
+ path: |
+ ${{ runner.temp }}/ui-review/*.png
+ ${{ runner.temp }}/ui-review/*.txt
+ ${{ runner.temp }}/ui-review/*.log
+ ${{ runner.temp }}/ui-review/ssh-copy-probe/*.json
+ ${{ runner.temp }}/ui-review/ssh-copy-probe/*.log
+ if-no-files-found: error
+ retention-days: 14
+ compression-level: 0
+
+ - name: Add screenshot pointers to job summary
+ if: always()
+ shell: bash
+ run: |
+ {
+ echo "## Native UI review"
+ echo
+ echo "- Platform: ${{ matrix.platform }}"
+ echo "- Scenario: $UI_REVIEW_SCENARIO"
+ echo "- Changed UI target: $UI_REVIEW_TARGET"
+ echo "- Download the ui-review-${{ matrix.platform }} artifact to inspect the PNG evidence."
+ } >>"$GITHUB_STEP_SUMMARY"
+
+
+ publish-evidence:
+ name: Publish UI evidence
+ if: always() && github.repository == 'WilliamWang1721/pebrel' && github.event_name == 'push'
+ needs: native-ui
+ runs-on: ubuntu-22.04
+ concurrency:
+ group: ui-review-evidence-publish
+ cancel-in-progress: false
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: true
+
+ - uses: actions/download-artifact@v4
+ continue-on-error: true
+ with:
+ name: ui-review-windows
+ path: ${{ runner.temp }}/ui-review-evidence/windows
+
+ - uses: actions/download-artifact@v4
+ continue-on-error: true
+ with:
+ name: ui-review-macos
+ path: ${{ runner.temp }}/ui-review-evidence/macos
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Polish screenshots for PR presentation
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 -m pip install --disable-pip-version-check --quiet "Pillow==11.3.0"
+ python3 - <<'PY'
+ import os
+ from pathlib import Path
+ from PIL import Image, ImageDraw, ImageFilter, ImageStat
+
+ source = Path(os.environ["RUNNER_TEMP"]) / "ui-review-evidence"
+ output = Path(os.environ["RUNNER_TEMP"]) / "ui-review-polished"
+ scale = 2
+ radius = 24 * scale
+ padding = 36 * scale
+ shadow_pad = 18 * scale
+
+ for platform in ("windows", "macos"):
+ src_dir = source / platform
+ dst_dir = output / platform
+ dst_dir.mkdir(parents=True, exist_ok=True)
+ if not src_dir.exists():
+ continue
+
+ for path in sorted(src_dir.glob("*.png")):
+ image = Image.open(path).convert("RGB")
+ image = image.resize(
+ (image.width * scale, image.height * scale),
+ Image.Resampling.LANCZOS,
+ )
+ image = image.filter(ImageFilter.UnsharpMask(radius=0.7, percent=115, threshold=2))
+
+ stat = ImageStat.Stat(image.resize((1, 1)))
+ luminance = sum(stat.mean) / 3
+ background = (243, 245, 248) if luminance > 120 else (18, 20, 24)
+ border = (205, 209, 216) if luminance > 120 else (65, 69, 78)
+
+ frame_w = image.width + padding * 2
+ frame_h = image.height + padding * 2
+ canvas = Image.new("RGBA", (frame_w + shadow_pad * 2, frame_h + shadow_pad * 2), background + (255,))
+
+ shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
+ shadow_draw = ImageDraw.Draw(shadow)
+ x0 = shadow_pad + padding
+ y0 = shadow_pad + padding
+ x1 = x0 + image.width
+ y1 = y0 + image.height
+ shadow_draw.rounded_rectangle(
+ (x0 + 4 * scale, y0 + 7 * scale, x1 + 4 * scale, y1 + 7 * scale),
+ radius=radius,
+ fill=(0, 0, 0, 92),
+ )
+ shadow = shadow.filter(ImageFilter.GaussianBlur(12 * scale))
+ canvas.alpha_composite(shadow)
+
+ mask = Image.new("L", image.size, 0)
+ ImageDraw.Draw(mask).rounded_rectangle(
+ (0, 0, image.width - 1, image.height - 1),
+ radius=radius,
+ fill=255,
+ )
+ framed = Image.new("RGBA", image.size, (0, 0, 0, 0))
+ framed.paste(image, (0, 0), mask)
+ canvas.alpha_composite(framed, (x0, y0))
+
+ draw = ImageDraw.Draw(canvas)
+ draw.rounded_rectangle(
+ (x0, y0, x1 - 1, y1 - 1),
+ radius=radius,
+ outline=border + (255,),
+ width=2 * scale,
+ )
+
+ out = dst_dir / f"{path.stem}-review.png"
+ canvas.convert("RGB").save(out, "PNG", optimize=True)
+ PY
+
+ - name: Publish screenshots outside the PR diff
+ shell: bash
+ run: |
+ set -euo pipefail
+ source_dir="$RUNNER_TEMP/ui-review-evidence"
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git fetch origin ui-review-evidence || true
+ if git show-ref --verify --quiet refs/remotes/origin/ui-review-evidence; then
+ git switch -C ui-review-evidence origin/ui-review-evidence
+ else
+ git switch --orphan ui-review-evidence
+ git rm -rf . >/dev/null 2>&1 || true
+ fi
+
+ polished_dir="$RUNNER_TEMP/ui-review-polished"
+ root="ui-review/pr-$UI_REVIEW_PR"
+ rm -rf "$root"
+ mkdir -p "$root/windows" "$root/macos" "$root/raw/windows" "$root/raw/macos"
+ find "$polished_dir/windows" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/windows/" \; 2>/dev/null || true
+ find "$polished_dir/macos" -maxdepth 1 -type f -name '*-review.png' -exec cp {} "$root/macos/" \; 2>/dev/null || true
+ find "$source_dir/windows" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/windows/" \; 2>/dev/null || true
+ find "$source_dir/macos" -maxdepth 1 -type f -name '*.png' -exec cp {} "$root/raw/macos/" \; 2>/dev/null || true
+ printf '%s\n' "$GITHUB_SHA" >"$root/head-sha.txt"
+ printf '%s\n' "$UI_REVIEW_TARGET" >"$root/target.txt"
+
+ git add "$root"
+ if git diff --cached --quiet; then
+ exit 0
+ fi
+ git commit -m "docs(ui): publish PR #$UI_REVIEW_PR screenshots"
+ git push origin HEAD:ui-review-evidence
diff --git a/architecture/notes/nebula_app/gpui_shell/2026-09-24-macos-system-menu.md b/architecture/notes/nebula_app/gpui_shell/2026-09-24-macos-system-menu.md
new file mode 100644
index 00000000..cd5724d4
--- /dev/null
+++ b/architecture/notes/nebula_app/gpui_shell/2026-09-24-macos-system-menu.md
@@ -0,0 +1,56 @@
+# macOS system menu and close shortcuts
+
+## Status
+
+Accepted.
+
+## Context
+
+The GPUI workspace already closes terminal tabs with `cmd-w`, while native window
+close must preserve its document, session, and busy-process checks. macOS users
+also expect application quit and common editing commands in the system menu.
+
+## Evidence
+
+- [`keyboard_bindings.rs`](../../../../nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs)
+ registered `cmd-w` for `CloseActiveTerminal`.
+- [`closing.rs`](../../../../nebula_app/src/gpui_shell/workspace/closing.rs)
+ owns the existing window-close checks and persistence path.
+- [`shutdown.rs`](../../../../nebula_app/src/gpui_shell/workspace/windowing/shutdown.rs)
+ owns the save and approval steps before quitting.
+
+## Decision
+
+Use GPUI's native application menus. `cmd-q` calls the shared graceful quit path;
+`cmd-w` invokes the workspace close checks; terminal close moves to `cmd-shift-w`.
+The app menu exposes About, Settings, Services, Hide, Hide Others, and Quit, with
+File, Edit, View, and Window menus alongside it.
+
+## Rejected alternatives
+
+- Keep `cmd-w` for terminal close: conflicts with the expected system window-close
+ shortcut.
+- Call `remove_window()` directly from the menu: bypasses the workspace close
+ checks and session persistence.
+- Call `cx.quit()` directly: bypasses the shared quit approval and save path.
+
+## Consequences
+
+Window-menu close and the native close button share the same close behavior.
+The menu is rebuilt when the selected UI language changes. No dependency or saved
+format changes are introduced.
+
+## Validation
+
+English and Simplified Chinese catalog IDs were compared and are aligned; both
+catalogs parse as JSON; `git diff --check` passes. `cargo check` could not run
+because `cargo` is unavailable in the current environment. Native macOS menu
+behavior has not been exercised in a running app.
+
+## Supersedes
+
+None.
+
+## Revisit when
+
+The GPUI menu API or workspace shutdown and close flows change.
diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json
index 3a1643f1..3961aaf8 100644
--- a/nebula_app/i18n/en-US.json
+++ b/nebula_app/i18n/en-US.json
@@ -269,6 +269,16 @@
"new_folder": "New folder",
"new_tab": "New tab",
"new_window": "New window",
+ "close_window": "Close Window",
+ "hide_pebrel": "Hide Pebrel",
+ "hide_others": "Hide Others",
+ "quit_pebrel": "Quit Pebrel",
+ "menu_file": "File",
+ "menu_edit": "Edit",
+ "menu_view": "View",
+ "menu_window": "Window",
+ "menu_services": "Services",
+ "menu_minimize": "Minimize",
"close_tab": "Close tab",
"duplicate_tab": "Duplicate tab",
"terminal": "Terminal",
diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json
index 25ec97f8..96ba839d 100644
--- a/nebula_app/i18n/zh-CN.json
+++ b/nebula_app/i18n/zh-CN.json
@@ -269,6 +269,16 @@
"new_folder": "新建文件夹",
"new_tab": "新建标签",
"new_window": "新建窗口",
+ "close_window": "关闭窗口",
+ "hide_pebrel": "隐藏 Pebrel",
+ "hide_others": "隐藏其他应用",
+ "quit_pebrel": "退出 Pebrel",
+ "menu_file": "文件",
+ "menu_edit": "编辑",
+ "menu_view": "显示",
+ "menu_window": "窗口",
+ "menu_services": "服务",
+ "menu_minimize": "最小化",
"close_tab": "关闭标签",
"duplicate_tab": "复制标签",
"terminal": "终端",
diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs
index f0d7be7a..d52e2a74 100644
--- a/nebula_app/src/gpui_shell/workspace.rs
+++ b/nebula_app/src/gpui_shell/workspace.rs
@@ -45,10 +45,12 @@ use gpui_component::input::InputEvent;
use gpui_component::notification::Notification;
use nebula_split::{DIVIDER_GAP, HIT_SLOP, RemoveOutcome, SplitDirection, SplitNav, SplitTree};
+mod actions;
mod agents;
mod closing;
mod command_manager;
mod keyboard_bindings;
+mod macos_menu;
#[cfg(test)]
use keyboard_bindings::{
STATIC_DEFAULT_COMBOS, custom_workspace_binding, default_workspace_bindings, gpui_binding_combo,
@@ -92,49 +94,13 @@ pub(crate) mod windowing;
// 调用点分散在设置页与窗口层,原样再导出以免拆分波及它们。
pub(crate) use update_dialog::{open_update_dialog, show_update_notification};
+use actions::*;
use tab_drag::{DockTarget, TabDrag, TabDragAxis};
#[cfg(test)]
use agents::ai_hook_target_pane;
use agents::{ai_session_palette_rows, restored_agent_command};
-gpui::actions!(
- nebula_workspace,
- [
- NewTerminal,
- NewWindow,
- CloseActiveTerminal,
- ToggleSidebar,
- OpenSettings,
- ToggleCommandPalette,
- CloseCommandPalette,
- ToggleShellPicker,
- CommandPaletteUp,
- CommandPaletteDown,
- ToggleFileTree,
- ToggleGitPanel,
- SplitRight,
- SplitDown,
- RenameActiveTab,
- ToggleZoom,
- FocusPaneLeft,
- FocusPaneRight,
- FocusPaneUp,
- FocusPaneDown,
- SelectNextTab,
- SelectPreviousTab,
- MoveTabLeft,
- MoveTabRight,
- IncreaseFontSize,
- DecreaseFontSize,
- ResetFontSize,
- CopySelection,
- PasteClipboard,
- ToggleFullscreen,
- OpenQuickJump
- ]
-);
-
/// 命令面板 / Shell 选择器罩层的 keymap context。Esc 必须挂在这里,
/// 不能挂全局:否则 CC/Codex 的终止对话键到不了 PTY。
const PALETTE_KEY_CONTEXT: &str = "NebulaCommandPalette";
@@ -171,6 +137,9 @@ fn title_bar_panel_controls() -> gpui::Div {
/// 注册工作区快捷键;在 `gpui_component::init` 之后调用一次。
pub fn init(cx: &mut App) {
keyboard_bindings::init(cx);
+ if crate::platform::CAPABILITIES.native_application_menu {
+ macos_menu::init(cx);
+ }
}
/// 一个终端 pane:视图实体 + 宿主订阅。id 即 `TerminalView::pane_id`
@@ -1244,7 +1213,13 @@ impl NebulaWorkspace {
/// follow_system 折算)、逐终端刷新、重建 chrome 令牌。
fn apply_runtime_settings(&mut self, cx: &mut Context) {
let (runtime, settings) = crate::gpui_shell::config::Settings::load_current_snapshot(cx);
+ let language_changed = cx
+ .try_global::()
+ .is_none_or(|previous| previous.ui_language != settings.ui_language);
cx.set_global(settings);
+ if crate::platform::CAPABILITIES.native_application_menu && language_changed {
+ cx.defer(|cx| macos_menu::init(cx));
+ }
for tab in &self.tabs {
if let WorkspaceTab::Terminal { panes, .. } = tab {
for pane in panes {
@@ -3237,6 +3212,20 @@ impl Render for NebulaWorkspace {
}
});
}))
+ .on_action(cx.listener(|this, _: &OpenAbout, window, cx| {
+ this.open_settings(window, cx);
+ }))
+ .on_action(cx.listener(|this, _: &CloseWindow, window, cx| {
+ if this.should_close_window(window, cx) {
+ window.remove_window();
+ }
+ }))
+ .on_action(cx.listener(|_, _: &QuitApplication, _, cx| {
+ windowing::quit_all(cx);
+ }))
+ .on_action(cx.listener(|_, _: &HideApplication, _, cx| cx.hide()))
+ .on_action(cx.listener(|_, _: &HideOtherApplications, _, cx| cx.hide_other_apps()))
+ .on_action(cx.listener(|_, _: &MinimizeWindow, window, _| window.minimize_window()))
.on_action(cx.listener(|this, _: &CloseActiveTerminal, window, cx| {
this.close_active(window, cx);
}))
@@ -3329,6 +3318,12 @@ impl Render for NebulaWorkspace {
cx.propagate();
}
}))
+ .on_action(cx.listener(|this, _: &gpui_component::input::Copy, window, cx| {
+ this.copy_focused_terminal(window, cx);
+ }))
+ .on_action(cx.listener(|this, _: &gpui_component::input::Paste, window, cx| {
+ this.paste_focused_terminal(window, cx);
+ }))
.on_action(cx.listener(|this, _: &PasteClipboard, window, cx| {
this.paste_focused_terminal(window, cx);
}))
diff --git a/nebula_app/src/gpui_shell/workspace/actions.rs b/nebula_app/src/gpui_shell/workspace/actions.rs
new file mode 100644
index 00000000..91f6e991
--- /dev/null
+++ b/nebula_app/src/gpui_shell/workspace/actions.rs
@@ -0,0 +1,42 @@
+gpui::actions!(
+ nebula_workspace,
+ [
+ NewTerminal,
+ NewWindow,
+ CloseActiveTerminal,
+ OpenAbout,
+ CloseWindow,
+ QuitApplication,
+ HideApplication,
+ HideOtherApplications,
+ MinimizeWindow,
+ ToggleSidebar,
+ OpenSettings,
+ ToggleCommandPalette,
+ CloseCommandPalette,
+ ToggleShellPicker,
+ CommandPaletteUp,
+ CommandPaletteDown,
+ ToggleFileTree,
+ ToggleGitPanel,
+ SplitRight,
+ SplitDown,
+ RenameActiveTab,
+ ToggleZoom,
+ FocusPaneLeft,
+ FocusPaneRight,
+ FocusPaneUp,
+ FocusPaneDown,
+ SelectNextTab,
+ SelectPreviousTab,
+ MoveTabLeft,
+ MoveTabRight,
+ IncreaseFontSize,
+ DecreaseFontSize,
+ ResetFontSize,
+ CopySelection,
+ PasteClipboard,
+ ToggleFullscreen,
+ OpenQuickJump
+ ]
+);
diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs
index 1ccd710e..d18a8382 100644
--- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs
+++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs
@@ -128,7 +128,8 @@ pub(super) fn default_workspace_bindings() -> Vec {
bindings
}
-/// macOS 的原生修饰键是 ⌘:在 Ctrl 绑定之外**追加**一套 ⌘ 绑定,不替换。
+/// macOS 的原生修饰键是 ⌘:在 Ctrl 绑定之外追加一套 ⌘ 绑定。⌘W 关闭窗口,
+/// 活动终端改用 ⌘⇧W,避免与系统关闭窗口命令冲突。
/// 追加而非替换有两个原因:Ctrl+Shift 组合在 Mac 终端里没有别的含义,留着
/// 不碍事;而 ⌘C/⌘V 必须存在,否则 Mac 用户第一反应就是「复制粘贴坏了」。
/// 终端里的 Ctrl+C 仍然是 SIGINT——这里只绑 ⌘,不碰 Ctrl 的语义。
@@ -137,7 +138,12 @@ fn bind_macos_command_keys(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("cmd-t", NewTerminal, None),
KeyBinding::new("cmd-n", NewWindow, None),
- KeyBinding::new("cmd-w", CloseActiveTerminal, None),
+ KeyBinding::new("cmd-q", QuitApplication, None),
+ KeyBinding::new("cmd-w", CloseWindow, None),
+ KeyBinding::new("cmd-shift-w", CloseActiveTerminal, None),
+ KeyBinding::new("cmd-h", HideApplication, None),
+ KeyBinding::new("cmd-alt-h", HideOtherApplications, None),
+ KeyBinding::new("cmd-m", MinimizeWindow, None),
KeyBinding::new("cmd-b", ToggleSidebar, None),
KeyBinding::new("cmd-,", OpenSettings, None),
KeyBinding::new("cmd-shift-p", ToggleCommandPalette, None),
diff --git a/nebula_app/src/gpui_shell/workspace/macos_menu.rs b/nebula_app/src/gpui_shell/workspace/macos_menu.rs
new file mode 100644
index 00000000..5997486b
--- /dev/null
+++ b/nebula_app/src/gpui_shell/workspace/macos_menu.rs
@@ -0,0 +1,55 @@
+use gpui::{App, Menu, MenuItem, OsAction, SystemMenuType};
+
+use super::*;
+use crate::i18n::Message;
+
+pub(super) fn init(cx: &mut App) {
+ let language = super::workspace_ui_language();
+ let text = |message| language.text(message);
+ cx.set_menus([
+ Menu::new("Pebrel").items([
+ MenuItem::action(format!("{} Pebrel", text(Message::CommonAbout)), OpenAbout),
+ MenuItem::separator(),
+ MenuItem::action(text(Message::CommonSettings), OpenSettings),
+ MenuItem::separator(),
+ MenuItem::os_submenu(text(Message::CommonMenuServices), SystemMenuType::Services),
+ MenuItem::separator(),
+ MenuItem::action(text(Message::CommonHidePebrel), HideApplication),
+ MenuItem::action(text(Message::CommonHideOthers), HideOtherApplications),
+ MenuItem::separator(),
+ MenuItem::action(text(Message::CommonQuitPebrel), QuitApplication),
+ ]),
+ Menu::new(text(Message::CommonMenuFile)).items([
+ MenuItem::action(text(Message::CommonNewWindow), NewWindow),
+ MenuItem::action(text(Message::CommonNewTab), NewTerminal),
+ MenuItem::separator(),
+ MenuItem::action(text(Message::CommonCloseWindow), CloseWindow),
+ ]),
+ Menu::new(text(Message::CommonMenuEdit)).items([
+ MenuItem::os_action(
+ text(Message::CommonCut),
+ gpui_component::input::Cut,
+ OsAction::Cut,
+ ),
+ MenuItem::os_action(
+ text(Message::CommonCopy),
+ gpui_component::input::Copy,
+ OsAction::Copy,
+ ),
+ MenuItem::os_action(
+ text(Message::CommonPaste),
+ gpui_component::input::Paste,
+ OsAction::Paste,
+ ),
+ MenuItem::os_action(
+ text(Message::CommonSelectAll),
+ gpui_component::input::SelectAll,
+ OsAction::SelectAll,
+ ),
+ ]),
+ Menu::new(text(Message::CommonMenuView))
+ .items([MenuItem::action(text(Message::EditorFullscreen), ToggleFullscreen)]),
+ Menu::new(text(Message::CommonMenuWindow))
+ .items([MenuItem::action(text(Message::CommonMenuMinimize), MinimizeWindow)]),
+ ]);
+}
diff --git a/nebula_app/src/platform/capabilities.rs b/nebula_app/src/platform/capabilities.rs
index c898a25f..364fe450 100644
--- a/nebula_app/src/platform/capabilities.rs
+++ b/nebula_app/src/platform/capabilities.rs
@@ -19,6 +19,8 @@ pub struct Capabilities {
pub launch_at_login: bool,
/// 系统通知后端已实现;实际投递仍受系统通知权限控制。
pub system_notifications: bool,
+ /// Native GPUI application menu is installed on macOS.
+ pub native_application_menu: bool,
/// 系统提示音(`platform::beep`)。
pub system_bell: bool,
/// Verified Windows installer or macOS bundle replacement is implemented.
@@ -43,6 +45,7 @@ pub const CAPABILITIES: Capabilities = {
system_tray: true,
launch_at_login: true,
system_notifications: true,
+ native_application_menu: false,
system_bell: true,
self_update_install: true,
ai_hook_server: true,
@@ -59,6 +62,7 @@ pub const CAPABILITIES: Capabilities = {
system_tray: false,
launch_at_login: false,
system_notifications: true,
+ native_application_menu: cfg!(target_os = "macos"),
system_bell: false,
self_update_install: cfg!(target_os = "macos"),
ai_hook_server: false,
@@ -89,6 +93,7 @@ mod tests {
CAPABILITIES.credential_store,
CAPABILITIES.shell_context_menu,
];
+ assert_eq!(CAPABILITIES.native_application_menu, cfg!(target_os = "macos"));
if cfg!(windows) {
assert!(all.iter().all(|flag| *flag));
} else {