From 667e9f10a1871e3b03cb90626eb5c3af2ba7184c Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 13:24:57 +0800 Subject: [PATCH 01/12] fix(windows): invoke Codex hooks with PowerShell and bound helper lifetime --- .../2026-09-23-bounded-forwarding-lifetime.md | 63 ++++++++ nebula_app/src/ai_hook/win/codex_hooks.rs | 78 +++++++++- nebula_hook/src/main.rs | 34 ++++- nebula_hook/tests/lifetime.rs | 142 ++++++++++++++++++ 4 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 architecture/notes/nebula_hook/2026-09-23-bounded-forwarding-lifetime.md create mode 100644 nebula_hook/tests/lifetime.rs diff --git a/architecture/notes/nebula_hook/2026-09-23-bounded-forwarding-lifetime.md b/architecture/notes/nebula_hook/2026-09-23-bounded-forwarding-lifetime.md new file mode 100644 index 0000000..a3cf026 --- /dev/null +++ b/architecture/notes/nebula_hook/2026-09-23-bounded-forwarding-lifetime.md @@ -0,0 +1,63 @@ +# Bound the lifetime of one-shot Hook forwarding + +## Status + +Implemented; pending review. + +## Context + +[Issue #258](https://github.com/Kuddev/pebrel/issues/258) reports an update that +first failed because `pebrel-hook.exe` remained in use after the application +closed, followed by a successful same-version repair incorrectly marked failed. +The helper must finish even when its caller or notification receiver stalls. + +## Evidence + +`nebula_hook/src/main.rs` drained stdin through `read_to_end` and wrote the +notification through synchronous `write_all`. Neither had a deadline. A caller +retaining stdin can prevent EOF; a receiver that stops reading can block a write. +Both conditions are reproduced by actual child processes in +[`lifetime.rs`](../../../nebula_hook/tests/lifetime.rs). + +## Decision + +Run the existing forwarding operation on one process-owned worker. The main +thread waits up to two seconds, then returns normally. Returning from this +one-shot process releases its worker and OS handles without leaving a resident +thread. This bounds stdin, oversize-input draining and notification I/O together. +No new dependency, background application service or platform thread is added. + +The user's chained notifier is launched independently after forwarding/waiting; +it is neither joined nor terminated. Cursor's submission-allow response remains +on the main path, including timeout. All provider-visible exits remain successful. + +## Rejected alternatives + +- Timing only stdin leaves a blocked pipe write alive. +- Joining the worker after timeout defeats the bound. +- Terminating every process named `pebrel-hook.exe` can affect another installation + or invocation. This change retires only the helper's own process. +- Skipping stdin entirely breaks providers which stream their payload into it. + +## Consequences + +A stalled or unusually slow invocation can lose its best-effort notification. +The forwarding wait is bounded; this is not a universal process startup latency +guarantee. Already running binaries from an older installation do not acquire +the new deadline and may still require the user to close the owning CLI/restart. + +## Validation + +`cargo test --locked -p nebula_hook`: seven unit tests and five actual-process +tests passed on Windows, covering retained stdin, a connected non-reading named +pipe, normal completion, Cursor continuation and the original chained notifier. +Non-Windows integration paths still require their native CI run. + +## Supersedes + +None. + +## Revisit when + +Providers require a longer documented delivery window or the transport becomes +cancellable without a per-invocation worker. diff --git a/nebula_app/src/ai_hook/win/codex_hooks.rs b/nebula_app/src/ai_hook/win/codex_hooks.rs index 4ae9a11..742b9ab 100644 --- a/nebula_app/src/ai_hook/win/codex_hooks.rs +++ b/nebula_app/src/ai_hook/win/codex_hooks.rs @@ -111,8 +111,10 @@ pub(super) fn ensure_codex_hooks() -> bool { } fn groups(helper: &str, mode: CodexHookMode) -> Value { - // Codex 的 Windows runner 使用 COMSPEC /C;可执行文件路径自身仍需引号。 - let command = format!("\"{}\" codex {}", helper.replace('"', "\\\""), mode.argument()); + // Codex runs Windows hooks through PowerShell -Command. A quoted path is + // a string expression until invoked with &, and single quotes keep $, ` + // and other path characters literal. PowerShell escapes ' by doubling it. + let command = format!("& '{}' codex {}", helper.replace('\'', "''"), mode.argument()); installation::codex_groups(&command, Some(&command), mode) } @@ -260,6 +262,78 @@ fn remove(directory: &Path) -> io::Result { mod tests { use super::*; + #[test] + fn upgrades_cmd_style_owned_hooks_without_changing_user_entries() { + let root = tempfile::tempdir().unwrap(); + let helper = "C:/Program Files/Pebrel/runtime/pebrel-hook.exe"; + let old_command = format!("\"{helper}\" codex --hooks=full"); + let previous = + installation::codex_groups(&old_command, Some(&old_command), CodexHookMode::Full); + let foreign = json!({"hooks":[{"type":"command","command":"user-hook"}]}); + let mut hooks = json!({"hooks": previous}); + hooks["hooks"]["Stop"].as_array_mut().unwrap().push(foreign.clone()); + std::fs::write(root.path().join("hooks.json"), hooks.to_string()).unwrap(); + std::fs::write( + root.path().join(MARKER), + json!({"groups": previous, "enabled_feature": true}).to_string(), + ) + .unwrap(); + let config = "notify = ['user-notifier']\n[features]\nhooks = true\n"; + std::fs::write(root.path().join("config.toml"), config).unwrap(); + + assert!(!current_for_mode(root.path(), helper, Some(CodexHookMode::Full)).unwrap()); + assert!(install(root.path(), helper, CodexHookMode::Full).unwrap()); + assert!(current_for_mode(root.path(), helper, Some(CodexHookMode::Full)).unwrap()); + assert!(!install(root.path(), helper, CodexHookMode::Full).unwrap()); + let hooks: Value = + serde_json::from_str(&read(&root.path().join("hooks.json")).unwrap().unwrap()).unwrap(); + let mut expected = json!({"hooks": groups(helper, CodexHookMode::Full)}); + expected["hooks"]["Stop"].as_array_mut().unwrap().insert(0, foreign); + assert_eq!(hooks, expected); + assert_eq!(read(&root.path().join("config.toml")).unwrap().unwrap(), config); + } + + #[test] + fn powershell_executes_generated_hooks_with_literal_paths_and_stdin() { + use std::io::Write as _; + use std::os::windows::process::CommandExt as _; + + let root = tempfile::tempdir().unwrap(); + let helper = root.path().join("Pebrel's $data `hook.ps1"); + std::fs::write( + &helper, + "Write-Output ($args -join '|')\r\nWrite-Output ([Console]::In.ReadLine())\r\n", + ) + .unwrap(); + let powershell = Path::new(&std::env::var_os("SystemRoot").unwrap()) + .join("System32/WindowsPowerShell/v1.0/powershell.exe"); + for mode in [CodexHookMode::Turns, CodexHookMode::Full] { + let groups = groups(&helper.to_string_lossy().replace('\\', "/"), mode); + let command = groups["Stop"][0]["hooks"][0]["command"].as_str().unwrap(); + let mut child = Command::new(&powershell) + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + command, + ]) + .creation_flags(0x0800_0000) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(b"{\"session_id\":\"fixture\"}\n").unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains(&format!("codex|{}", mode.argument())), "{stdout}"); + assert!(stdout.contains(r#"{"session_id":"fixture"}"#), "{stdout}"); + } + } + #[test] fn install_upgrade_remove_preserves_notify_and_user_hooks() { let root = tempfile::tempdir().unwrap(); diff --git a/nebula_hook/src/main.rs b/nebula_hook/src/main.rs index 1c6b9f6..0868f21 100644 --- a/nebula_hook/src/main.rs +++ b/nebula_hook/src/main.rs @@ -12,14 +12,15 @@ //! surfaces an error banner, 2 even blocks the turn), and kimi's `Stop` is //! likewise a blockable event. Every path — including panic — must exit 0, //! fast. Claude and kimi also write the payload to our stdin, so those modes -//! always drain stdin even when the message goes nowhere: an unread pipe -//! could surface as a hook write error. +//! drain stdin within a bounded invocation even when the message goes nowhere. +//! A caller retaining stdin or a stalled receiver must not leave this helper +//! running indefinitely and holding the installed executable open. //! 2. SCOPED: the hook config is global (settings.json / kimi's config.toml), //! but the effect must be Nebula-only. The scope guard is the environment: //! NEBULA_NOTIFY_PIPE only exists for processes spawned inside Nebula. -//! Anywhere else this is an invisible ~10 ms no-op. -//! 3. FAST: pure std, no JSON handling (Nebula parses), one pipe write. -//! Keeps the whole claude→toast chain under ~50 ms. +//! Anywhere else it forwards nothing and exits without affecting the caller. +//! 3. BOUNDED: pure std, no JSON handling (Pebrel parses), one pipe write. +//! Forwarding has a deadline; startup and notification latency depend on the host. //! //! Usage (installed by `nebula setup-ai` / Nebula's boot self-heal): //! ```text @@ -42,6 +43,7 @@ use std::io::{Read, Write}; const MAX_PAYLOAD_BYTES: usize = 1 << 20; +const FORWARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); /// 其他 agent 的 hook runner 独有的环境变量。 /// @@ -167,7 +169,25 @@ fn log_outcome(source: &str, pane: &str, bytes: usize, outcome: &Outcome) { fn main() { // Constraint 1: never leak a failure to the calling CLI. let args: Vec = std::env::args().skip(1).collect(); - let _ = std::panic::catch_unwind(|| run(&args)); + let forwarding_args = args.clone(); + let (done, finished) = std::sync::mpsc::sync_channel(1); + // Bound the entire forwarding operation, including stdin drain and pipe + // writes. A read-only timeout would still leave a blocked writer alive. + // This one-shot process never joins a stuck worker: returning from main + // retires all of its threads and handles. The provider sees exit code 0. + if std::thread::Builder::new() + .name("hook-forward".into()) + .spawn(move || { + let _ = std::panic::catch_unwind(|| run(&forwarding_args)); + let _ = done.send(()); + }) + .is_ok() + { + let _ = finished.recv_timeout(FORWARD_TIMEOUT); + } + // A user-owned notifier is independent of our best-effort transport. Never + // wait for it and do not suppress it when the Pebrel pipe is unavailable. + chain_notifier(&args); // Cursor 的提交前 Hook 有响应合同;传输失败也不能阻止用户提交。 if args.first().is_some_and(|source| source == "cursor") && native_event(&args) == Some("prompt") @@ -307,7 +327,9 @@ fn run(args: &[String]) { } } log_outcome(source, &pane, payload.len(), &outcome); +} +fn chain_notifier(args: &[String]) { // Chain mode: keep a pre-existing codex notifier working. Runs even // outside Nebula — the original program must keep firing everywhere. let strs: Vec<&str> = args.iter().map(String::as_str).collect(); diff --git a/nebula_hook/tests/lifetime.rs b/nebula_hook/tests/lifetime.rs new file mode 100644 index 0000000..925f9ae --- /dev/null +++ b/nebula_hook/tests/lifetime.rs @@ -0,0 +1,142 @@ +//! Real-process lifetime regressions: a provider may retain its stdin handle, +//! and a pipe receiver may stop draining. Neither can pin the installed helper. + +use std::io::Write as _; +use std::process::{Child, Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +struct Invocation(Option); + +impl Invocation { + fn spawn(args: &[&str], pipe: Option<&str>) -> Self { + let mut command = Command::new(env!("CARGO_BIN_EXE_pebrel-hook")); + command.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); + for suffix in ["NOTIFY_PIPE", "REMOTE_HOOK_TOKEN", "HOOK_LOG"] { + command.env_remove(format!("PEBREL_{suffix}")); + command.env_remove(format!("NEBULA_{suffix}")); + } + if let Some(pipe) = pipe { + command.env("PEBREL_NOTIFY_PIPE", pipe); + } + Self(Some(command.spawn().unwrap())) + } + + fn finish(mut self) -> Output { + let deadline = Instant::now() + Duration::from_secs(6); + let child = self.0.as_mut().unwrap(); + loop { + if child.try_wait().unwrap().is_some() { + let output = self.0.take().unwrap().wait_with_output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + return output; + } + assert!(Instant::now() < deadline, "helper retained a blocked invocation"); + std::thread::sleep(Duration::from_millis(10)); + } + } +} + +impl Drop for Invocation { + fn drop(&mut self) { + if let Some(child) = &mut self.0 { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +#[test] +fn stdin_kept_open_cannot_pin_a_native_hook_executable() { + for args in [&["codex", "--hooks=full"][..], &["claude"][..]] { + let mut invocation = Invocation::spawn(args, None); + let mut input = invocation.0.as_mut().unwrap().stdin.take().unwrap(); + input.write_all(b"{\"hook_event_name\":\"Stop\"}").unwrap(); + // Hold stdin open across finish: EOF must not be necessary for exit. + let output = invocation.finish(); + assert!(output.stdout.is_empty()); + drop(input); + } +} + +#[test] +fn cursor_still_allows_submission_when_stdin_never_closes() { + let mut invocation = Invocation::spawn(&["cursor", "--event", "prompt"], None); + let input = invocation.0.as_mut().unwrap().stdin.take().unwrap(); + let output = invocation.finish(); + assert_eq!(output.stdout, b"{\"continue\":true}\n"); + drop(input); +} + +#[test] +fn ordinary_completed_payload_exits_silently() { + let mut invocation = Invocation::spawn(&["codex", "--hooks=full"], None); + invocation.0.as_mut().unwrap().stdin.take().unwrap().write_all(b"{}").unwrap(); + let output = invocation.finish(); + assert!(output.stdout.is_empty() && output.stderr.is_empty()); +} + +#[test] +fn legacy_notify_still_invokes_the_users_chained_program() { + let executable = std::env::current_exe().unwrap(); + let invocation = Invocation::spawn( + &["codex", "--chain", executable.to_str().unwrap(), "--list", "--format=terse"], + None, + ); + let output = invocation.finish(); + assert!( + String::from_utf8(output.stdout) + .unwrap() + .contains("ordinary_completed_payload_exits_silently") + ); +} + +#[cfg(windows)] +#[test] +fn a_receiver_that_never_reads_cannot_pin_the_helper() { + use std::ffi::c_void; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn CreateNamedPipeW( + name: *const u16, + mode: u32, + pipe_mode: u32, + instances: u32, + output: u32, + input: u32, + timeout: u32, + security: *const c_void, + ) -> *mut c_void; + fn CloseHandle(handle: *mut c_void) -> i32; + fn GetNamedPipeClientProcessId(handle: *mut c_void, pid: *mut u32) -> i32; + } + struct Pipe(*mut c_void); + impl Drop for Pipe { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } + let name = format!(r"\\.\pipe\pebrel-hook-lifetime-test-{}", std::process::id()); + let wide: Vec = name.encode_utf16().chain(Some(0)).collect(); + // The server owns an inbound byte pipe and deliberately never drains it. + let handle = + unsafe { CreateNamedPipeW(wide.as_ptr(), 1, 0, 1, 4096, 4096, 0, std::ptr::null()) }; + assert_ne!(handle as isize, -1, "{}", std::io::Error::last_os_error()); + let _pipe = Pipe(handle); + let payload = "x".repeat(16 * 1024); + let invocation = Invocation::spawn(&["codex", &payload], Some(&name)); + let deadline = Instant::now() + Duration::from_secs(6); + loop { + let mut pid = 0; + if unsafe { GetNamedPipeClientProcessId(handle, &mut pid) } != 0 { + assert_eq!(pid, invocation.0.as_ref().unwrap().id()); + break; + } + assert!(Instant::now() < deadline, "helper never connected to the blocked receiver"); + std::thread::sleep(Duration::from_millis(10)); + } + let output = invocation.finish(); + assert!(output.stdout.is_empty() && output.stderr.is_empty()); +} From df34a7d91aec94bd3c6baebb41418736b8e4f88c Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 13:27:36 +0800 Subject: [PATCH 02/12] fix(updater): accept successful byte-identical same-version repairs --- .../2026-09-23-same-version-repair.md | 55 +++++++++++++++++++ nebula_app/src/update_download/handoff.ps1 | 6 +- scripts/tests/fixtures/update_process.cs | 2 + scripts/tests/test_update_handoff.ps1 | 15 ++--- 4 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 architecture/notes/packaging/2026-09-23-same-version-repair.md diff --git a/architecture/notes/packaging/2026-09-23-same-version-repair.md b/architecture/notes/packaging/2026-09-23-same-version-repair.md new file mode 100644 index 0000000..ba603f0 --- /dev/null +++ b/architecture/notes/packaging/2026-09-23-same-version-repair.md @@ -0,0 +1,55 @@ +# Same-version repair is not identified by a changed main binary + +## Status + +Implemented; pending review. + +## Context + +[Issue #258](https://github.com/Kuddev/pebrel/issues/258) supplies an Inno log +reporting successful installation and the expected Pebrel version, while the +update helper reports that the same-version binary did not change. + +## Evidence + +The reported preceding update replaced the main executable but failed with a +locked Hook helper. Reinstalling the same package repairs other files while +leaving the main executable byte-identical. Its hash cannot distinguish that +successful repair from an installation which did no useful work. + +## Decision + +Retain package SHA-256/size checks, commit authority, participant identity and +exit waiting, installer exit-code checks, and the installed version probe. +Remove the additional requirement that a same-version main binary change hash. +Record the installed hash as evidence, not as a repair-success predicate. + +## Rejected alternatives + +- Suppressing every installer error would hide partial/failed installations. +- Forcing a main-binary byte difference does not validate the repaired files. +- Disabling same-version retries prevents recovery after a partial upgrade. + +## Consequences + +Installer success and the expected application version determine success for a +verified package. The updater does not independently audit every installed file; +installer errors continue to fail the transaction. No installed version tag or +asset naming rule changes. + +## Validation + +The native handoff suite passes ten scenarios, including a byte-identical main +binary with a repaired helper. The former same-version/no-change assertion was +incorrect; its negative intent is retained as `upgrade-noop`, which returns +installer success but leaves the wrong application version and must still fail. +Cancellation, checksum/identity failures and unprepared processes remain covered. + +## Supersedes + +The same-version changed-hash assertion in the handoff helper and its old fixture. + +## Revisit when + +A versioned installed-file manifest provides a stronger package-completeness +check without confusing unchanged files with failed installation. diff --git a/nebula_app/src/update_download/handoff.ps1 b/nebula_app/src/update_download/handoff.ps1 index 5d47e2c..ccbdc63 100644 --- a/nebula_app/src/update_download/handoff.ps1 +++ b/nebula_app/src/update_download/handoff.ps1 @@ -144,9 +144,9 @@ try { throw 'Installed application did not report the expected version' } $installedDigest = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash - if ($plan.version -eq $plan.original_version -and $installedDigest -eq $originalDigest) { - throw 'Same-version installation did not replace the application binary' - } + # A repair can replace a previously locked helper while leaving pebrel.exe + # byte-identical. Setup success plus the expected version is authoritative; + # a changed main-executable hash is not a same-version success requirement. Write-State 'result.json' @{ transaction = $plan.transaction; success = $true; version = $plan.version executable_sha256 = $installedDigest diff --git a/scripts/tests/fixtures/update_process.cs b/scripts/tests/fixtures/update_process.cs index 5c2fca2..4bdc702 100644 --- a/scripts/tests/fixtures/update_process.cs +++ b/scripts/tests/fixtures/update_process.cs @@ -18,6 +18,8 @@ class UpdateProcess { if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_FAIL_INSTALL") == "1") return 17; if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_NOOP_INSTALL") == "1") return 0; File.Copy(Path.Combine(directory, "candidate.exe"), Path.Combine(target, "pebrel.exe"), true); + Directory.CreateDirectory(Path.Combine(target, "runtime")); + File.WriteAllText(Path.Combine(target, "runtime", "pebrel-hook.exe"), "repaired helper fixture"); return 0; #else if (args.Length > 0 && args[0] == "--version") { diff --git a/scripts/tests/test_update_handoff.ps1 b/scripts/tests/test_update_handoff.ps1 index 1901735..2e05324 100644 --- a/scripts/tests/test_update_handoff.ps1 +++ b/scripts/tests/test_update_handoff.ps1 @@ -36,7 +36,7 @@ function Write-Json([string]$Path, $Value) { } $results = @() -foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'reinstall-noop')) { +foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'repair-identical', 'upgrade-noop')) { $directory = Join-Path $OutputRoot $scenario $installation = Join-Path $directory 'installed app' $transaction = Join-Path $directory 'transaction' @@ -44,12 +44,12 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta $null = New-Item -ItemType Directory -Path $installation, $transaction, $config -Force $executable = Join-Path $installation 'pebrel.exe' Copy-Item -LiteralPath $old -Destination $executable - $payload = if ($scenario -eq 'reinstall') { $reinstall } else { $candidate } + $payload = if ($scenario -eq 'reinstall') { $reinstall } elseif ($scenario -eq 'repair-identical') { $old } else { $candidate } Copy-Item -LiteralPath $payload -Destination (Join-Path $directory 'candidate.exe') [System.IO.File]::WriteAllText((Join-Path $installation 'unins000.exe'), 'fixture marker') $env:PEBREL_HANDOFF_FIXTURE = $directory $env:PEBREL_HANDOFF_FAIL_INSTALL = if ($scenario -eq 'installer-failure') { '1' } else { '0' } - $env:PEBREL_HANDOFF_NOOP_INSTALL = if ($scenario -eq 'reinstall-noop') { '1' } else { '0' } + $env:PEBREL_HANDOFF_NOOP_INSTALL = if ($scenario -eq 'upgrade-noop') { '1' } else { '0' } $parent = Start-Process -FilePath $executable -ArgumentList 'wait' -PassThru $null = $parent.Handle $runner = $null @@ -64,7 +64,7 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta participants = @(@{ pid = $parent.Id; created = $parent.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() }) } if ($scenario -eq 'checksum') { $plan.sha256 = '0' * 64 } - if ($scenario -in @('reinstall', 'reinstall-noop')) { $plan.version = '1.8.0' } + if ($scenario -in @('reinstall', 'repair-identical')) { $plan.version = '1.8.0' } if ($scenario -eq 'creation-time') { $plan.participants[0].created = '1' } if ($scenario -eq 'other-process') { $other = Start-Process -FilePath $executable -ArgumentList 'wait-other' -PassThru @@ -105,7 +105,7 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta [System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit') Assert ($parent.WaitForExit(5000)) 'Fixture parent did not exit' Wait-File $resultPath - if ($scenario -in @('success', 'installer-failure', 'reinstall', 'reinstall-noop')) { + if ($scenario -in @('success', 'installer-failure', 'reinstall', 'repair-identical', 'upgrade-noop')) { Wait-File (Join-Path $directory 'new-launch') $launch = [System.IO.File]::ReadAllLines((Join-Path $directory 'new-launch')) Assert ($launch[0] -eq $executable) 'Relaunch selected another installation' @@ -117,16 +117,17 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta } Assert ($runner.WaitForExit(5000)) 'Helper did not finish' $result = Get-Content -LiteralPath $resultPath -Raw -Encoding UTF8 | ConvertFrom-Json - Assert ($result.success -eq ($scenario -in @('success', 'reinstall'))) 'Unexpected helper outcome' + Assert ($result.success -eq ($scenario -in @('success', 'reinstall', 'repair-identical'))) 'Unexpected helper outcome' if ($result.success) { $expected = (Get-FileHash -LiteralPath $payload -Algorithm SHA256).Hash Assert ($result.executable_sha256 -eq $expected) 'Installed binary differs from candidate' + Assert (Test-Path -LiteralPath (Join-Path $installation 'runtime\pebrel-hook.exe')) 'Successful setup did not repair the helper' } if ($scenario -in @('other-process', 'late-process')) { Assert (-not $other.HasExited) 'Update stopped an unprepared process' Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran with an unprepared process' } - if ($scenario -in @('installer-failure', 'reinstall-noop')) { + if ($scenario -in @('installer-failure', 'upgrade-noop')) { Assert $result.recovered_original 'Untouched old executable was not recovered' } $probe = [System.IO.FileStream]::new($plan.guard_path, [System.IO.FileMode]::OpenOrCreate, From ab22119e292fc582d65a4de66765e4dcd54e3e24 Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 13:27:46 +0800 Subject: [PATCH 03/12] fix(terminal): align link decoration with grid cells and shell paths --- nebula_app/src/config/ui_config.rs | 3 + nebula_app/src/display/hint.rs | 132 +++++++++++++++++- nebula_app/src/gpui_shell/terminal/element.rs | 120 +++++++++------- .../terminal/element/color_tests.rs | 99 +++++++++++++ .../src/gpui_shell/terminal/link_underline.rs | 132 ++++++++++++++++++ nebula_app/src/gpui_shell/terminal/mod.rs | 1 + .../src/gpui_shell/terminal/osc_links.rs | 80 ++++++++++- .../src/gpui_shell/terminal/view/pointer.rs | 9 +- scripts/tests/user-feedback-regressions.md | 59 ++++++++ 9 files changed, 577 insertions(+), 58 deletions(-) create mode 100644 nebula_app/src/gpui_shell/terminal/element/color_tests.rs create mode 100644 nebula_app/src/gpui_shell/terminal/link_underline.rs create mode 100644 scripts/tests/user-feedback-regressions.md diff --git a/nebula_app/src/config/ui_config.rs b/nebula_app/src/config/ui_config.rs index c2c3578..0c06cd5 100644 --- a/nebula_app/src/config/ui_config.rs +++ b/nebula_app/src/config/ui_config.rs @@ -37,6 +37,9 @@ use crate::config::window::WindowConfig; /// Regex used for default terminal hints (Markdown links, URLs, and local paths). #[rustfmt::skip] pub const DEFAULT_HINT_REGEX: &str = "(?:\ + (?m:^[a-z0-9_.-]+@[a-z0-9_.-]+:/[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s`]*[$#])|\ + \"(?:[a-z]:[/\\\\]|\\\\\\\\|~[/\\\\])[^\u{0000}-\u{001F}\u{007F}-\u{009F}\"\\r\\n]+\"|\ + '(?:[a-z]:[/\\\\]|\\\\\\\\|~[/\\\\])[^\u{0000}-\u{001F}\u{007F}-\u{009F}'\\r\\n]+'|\ \\[[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n\\]]+\\]\\((?:[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]|\\([^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]*\\))+\\)|\ (ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`\\\\]+|\ (?:(?-u:\\b)[a-z]:[/\\\\]|\\\\\\\\)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`*?:,。;:!?、]+|\ diff --git a/nebula_app/src/display/hint.rs b/nebula_app/src/display/hint.rs index 7e3b6ae..719c4e5 100644 --- a/nebula_app/src/display/hint.rs +++ b/nebula_app/src/display/hint.rs @@ -585,9 +585,45 @@ impl<'a, T> HintPostProcessor<'a, T> { let mut iter = self.term.grid().iter_from(*regex_match.start()); let mut c = iter.cell().c; + let mut start = *regex_match.start(); + let end = *regex_match.end(); + if start == self.term.line_search_left(start) && c.is_ascii_alphanumeric() { + let mut prefix = self.term.grid().iter_from(start); + let mut at = false; + loop { + let ch = prefix.cell().c; + if ch == ':' && at { + if let Some(next) = prefix.next() + && next.c == '/' + { + start = next.point; + iter = self.term.grid().iter_from(start); + c = iter.cell().c; + } + break; + } + if !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '@')) + || prefix.point() >= end + { + break; + } + at |= ch == '@'; + if prefix.next().is_none() { + break; + } + } + } + // Quoted local paths have an explicit boundary and may contain spaces, + // punctuation and parentheses. Keep the target, not its shell quotes. + if matches!(c, '\'' | '"') && start < end && self.term.grid()[end].c == c { + let inner_start = start.add(self.term, Boundary::Grid, 1); + let inner_end = end.sub(self.term, Boundary::Grid, 1); + if inner_start <= inner_end { + return Some(inner_start..=inner_end); + } + } // Truncate uneven number of brackets. - let end = *regex_match.end(); let mut open_parents = 0; let mut open_brackets = 0; loop { @@ -624,7 +660,14 @@ impl<'a, T> HintPostProcessor<'a, T> { } // Truncate trailing characters which are likely to be delimiters. - let start = *regex_match.start(); + // A default Bash/WSL prompt has no space between its cwd and $/#. + // Only trim that suffix in a user@host:~/... prompt; '$' is otherwise + // legal in paths and URLs, and OSC 8 targets bypass these heuristics. + if matches!(c, '$' | '#') && self.is_shell_prompt_path(start, iter.point()) { + if let Some(indexed) = iter.prev() { + c = indexed.cell.c; + } + } while iter.point() != start { if !matches!( c, @@ -660,6 +703,32 @@ impl<'a, T> HintPostProcessor<'a, T> { if start > iter.point() { None } else { Some(start..=iter.point()) } } + fn is_shell_prompt_path(&self, start: Point, end: Point) -> bool { + if !matches!(self.term.grid()[start].c, '~' | '/') { + return false; + } + let line_start = self.term.line_search_left(start); + if start == line_start { + return false; + } + let mut after = self.term.grid().iter_from(end); + if after.next().is_some_and(|cell| !cell.c.is_whitespace()) { + return false; + } + let before = start.sub(self.term, Boundary::Grid, 1); + let prefix = self.term.bounds_to_string(line_start, before); + let Some(prefix) = prefix.split_whitespace().next_back() else { return false }; + let Some((user, host)) = prefix.strip_suffix(':').and_then(|s| s.rsplit_once('@')) else { + return false; + }; + !user.is_empty() + && !host.is_empty() + && user + .chars() + .chain(host.chars()) + .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.')) + } + /// Loop over submatches until a non-empty post-processed match is found. fn next_processed_match(&mut self, mut regex_match: Match) { self.next_match = loop { @@ -705,6 +774,65 @@ mod tests { use super::*; + #[test] + fn shell_prompt_symbols_are_not_part_of_clickable_home_paths() { + for (text, expected) in [ + ("hello@userdk:/mnt/d/temp_build/nebula$ ", "/mnt/d/temp_build/nebula"), + ("root@host:/root# ", "/root"), + ("user@host:/# ", "/"), + ("hello@userdk:~/.codex$ ", "~/.codex"), + ("root@host:~/work# ", "~/work"), + ("hello@userdk:~/工作文档$ echo test", "~/工作文档"), + ("~/cost$ ", "~/cost$"), + ("~/repo# ", "~/repo#"), + ("https://example.com/cost$ ", "https://example.com/cost$"), + ("user@host:~/price$usd ", "~/price$usd"), + ] { + let term = mock_term(text); + let config = UiConfig::default(); + let matches = visible_clickable_matches(&term, &config); + assert_eq!(matches.len(), 1, "{text}: {matches:?}"); + assert_eq!(term.bounds_to_string(*matches[0].start(), *matches[0].end()), expected); + let end = *matches[0].end(); + let hit = highlighted_at(&term, &config, end, ModifiersState::CONTROL).unwrap(); + assert_eq!(hit.text(&term).unwrap().as_ref(), expected); + assert!( + highlighted_at( + &term, + &config, + end.add(&term, Boundary::Grid, 1), + ModifiersState::CONTROL + ) + .is_none() + ); + } + } + + #[test] + fn quoted_paths_keep_spaces_and_literal_punctuation_in_hover_and_click_targets() { + for (text, expected) in [ + ( + r#"& "C:\Program Files\Example Tools\probe.exe" pr view"#, + r"C:\Program Files\Example Tools\probe.exe", + ), + ("open 'D:/My Documents/开始 菜单 (1).md' now", "D:/My Documents/开始 菜单 (1).md"), + ("cat '~/cost$'", "~/cost$"), + (r#"open "\\server\my share\report.txt" now"#, r"\\server\my share\report.txt"), + ] { + let term = mock_term(text); + let config = UiConfig::default(); + let matches = visible_clickable_matches(&term, &config); + assert_eq!(matches.len(), 1, "{text}: {matches:?}"); + let start = *matches[0].start(); + let end = *matches[0].end(); + assert_eq!(term.bounds_to_string(start, end), expected); + for point in [start, end] { + let hit = highlighted_at(&term, &config, point, ModifiersState::CONTROL).unwrap(); + assert_eq!(hit.text(&term).unwrap().as_ref(), expected); + } + } + } + #[test] fn hint_label_generation() { let mut generator = HintLabels::new("0123", 0.5); diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index 416c684..5a6e07a 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -9,8 +9,6 @@ //! 共同塑形,再由 `ligatures` 把字形簇映射回固定列号。其余字符逐格 //! 绘制,组合字符跟随基字;编辑、光标和选区不能改变无关格的原点。 -use std::collections::HashSet; - use gpui::{ App, Bounds, ContentMask, Corners, CursorStyle, DispatchPhase, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, LayoutId, MouseButton, @@ -28,6 +26,10 @@ use nebula_terminal::vte::ansi::{Color, CursorShape, NamedColor}; use super::colors::Palette; use super::view::TerminalView; +#[cfg(test)] +#[path = "element/color_tests.rs"] +mod color_tests; + pub struct TerminalElement { view: gpui::Entity, } @@ -84,7 +86,8 @@ impl TerminalElement { rows: usize, cols: usize, cx: &App, - ) -> Option<(RenderSnapshot, Option, usize, HashSet<(u16, u16)>, usize, i64)> { + ) -> Option<(RenderSnapshot, Option, usize, super::osc_links::LinkCells, usize, i64)> + { let view = self.view.read(cx); let session = view.session.as_ref()?; let hint_config = view.hint_config.clone(); @@ -139,12 +142,23 @@ impl TerminalElement { fn resolve_app_colors( &self, snap: &mut RenderSnapshot, + links: &mut super::osc_links::LinkCells, theme: &Palette, overrides: &Colors, cx: &mut App, ) { self.view.update(cx, |view, _| { resolve_app_colors_into(snap, theme, overrides, &mut view.color_resolver); + for cell in links.values_mut() { + cell.fg = resolve_text_foreground( + cell.fg, + cell.bg, + cell.bold, + theme, + overrides, + &mut view.color_resolver, + ); + } }); } } @@ -281,7 +295,7 @@ impl Element for TerminalElement { let focused = focus_handle.is_focused(window); // 旧壳只让光标本身参与闪烁;ghost、弹窗补齐和 IME 仍复用同一个坐标锚点。 let cursor_visible = self.view.read(cx).cursor_visible(); - let Some((mut snap, prompt_line, history, dashed, scrollback_floor, viewport_top_abs)) = + let Some((mut snap, prompt_line, history, mut dashed, scrollback_floor, viewport_top_abs)) = self.snapshot(layout.rows, layout.cols, cx) else { return; @@ -295,7 +309,7 @@ impl Element for TerminalElement { view.drive_pending_remote_dir(cx); }); let overrides = snap.color_overrides; - self.resolve_app_colors(&mut snap, &theme, &overrides, cx); + self.resolve_app_colors(&mut snap, &mut dashed, &theme, &overrides, cx); let (theme_anchor, theme_is_light) = themed_anchor(&theme, cx); let host_cursor_follows_theme = is_default_host_cursor(&theme); let app_cursor = snap.cursor.as_ref().filter(|cursor| { @@ -606,7 +620,7 @@ impl Element for TerminalElement { } else { theme.resolve(cell.fg, &overrides, cell.bold).into() }; - let dashed_link = dashed.contains(&(seg.row, cell.col)); + let dashed_link = dashed.contains_key(&(seg.row, cell.col)); let underline = (cell.underline && !dashed_link).then(|| UnderlineStyle { thickness: px(1.0), color: Some(fg), @@ -639,7 +653,7 @@ impl Element for TerminalElement { == cursor_inverts(seg.row, cell.col) && selected_foreground(seg.row, next.col) == selected_foreground(seg.row, cell.col) - && !dashed.contains(&(seg.row, next.col)) + && !dashed.contains_key(&(seg.row, next.col)) }) } else { 1 @@ -674,18 +688,35 @@ impl Element for TerminalElement { layout.line_height, ); } - if dashed_link { - paint_dashed_underline( - window, - origin, - layout.cell_width, - layout.line_height, - fg, - ); - } } } + // Link decoration follows grid columns, including wide-character spacer + // cells and spaces omitted by text shaping. Every cell shares one dash + // phase, so font fallback and ASCII/CJK boundaries cannot restart it. + for (&(row, col), cell) in &dashed { + if math_frame.covers(row as usize, col as usize) { + continue; + } + let Some(visual_col) = math_frame.project_cell(row as usize, col as usize, layout.cols) + else { + continue; + }; + let color = if cursor_inverts(row, col) { + theme.cursor_text.unwrap_or(theme.background) + } else if let Some(foreground) = selected_foreground(row, col) { + foreground + } else { + theme.resolve(cell.fg, &overrides, cell.bold) + }; + super::link_underline::paint( + window, + cell_rect(row as usize, visual_col, 1), + bounds.origin.x, + color.into(), + ); + } + // 公式位图画在格子文本之后、装饰(ghost/光标/滚动条)之前, // 与旧壳 draw_rects → draw_overlays 的次序一致。 if !math_frame.is_empty() { @@ -945,27 +976,6 @@ impl Element for TerminalElement { } } -fn paint_dashed_underline( - window: &mut Window, - origin: gpui::Point, - cell_width: Pixels, - line_height: Pixels, - color: Hsla, -) { - let y = origin.y + line_height - px(1.0); - let end: f32 = origin.x.as_f32() + cell_width.as_f32(); - let mut x: f32 = origin.x.as_f32(); - let dash: f32 = 3.0; - let gap: f32 = 2.0; - while x < end { - let width = f32::min(dash, end - x); - if width > 0.0 { - window.paint_quad(fill(Bounds::new(point(px(x), y), size(px(width), px(1.0))), color)); - } - x += dash + gap; - } -} - /// Align geometry-only terminal primitives to the device pixel grid. Cell /// widths/heights are already measured in device pixels and converted back to /// logical units, but a pane origin can still be fractional after a split or @@ -1642,9 +1652,6 @@ fn resolve_app_colors_into( use crate::display::content::is_terminal_graphic; use crate::display::terminal_color::is_fixed_color; - let theme_fg = rgb_from_rgba(theme.foreground); - let theme_bg = rgb_from_rgba(theme.background); - for run in &mut snap.bg_runs { let base = rgb_from_rgba(theme.resolve(run.color, overrides, false)); let resolved = resolver.resolve_background(base, is_fixed_color(run.color, overrides)); @@ -1662,18 +1669,33 @@ fn resolve_app_colors_into( // 对比度是一对颜色的属性:这个前景可不可读,取决于它**这一格**底下是 // 什么,而不是主题底色。默认底色的格子没有 bg run,所以 `SnapCell::bg` // 单独带着这个值。 - let bg_base = rgb_from_rgba(theme.resolve(cell.bg, overrides, false)); - let bg = resolver.resolve_background(bg_base, is_fixed_color(cell.bg, overrides)); - // bold 提亮(0-7 → 8-15)必须发生在矫正**之前**,否则写回的 `Spec` 会把 - // 提亮吃掉。 - let base = rgb_from_rgba(theme.resolve(cell.fg, overrides, cell.bold)); - let resolved = resolver.resolve_foreground(base, bg, true, theme_fg, theme_bg); - if resolved != base { - cell.fg = Color::Spec(resolved.0); - } + cell.fg = resolve_text_foreground(cell.fg, cell.bg, cell.bold, theme, overrides, resolver); } } +fn resolve_text_foreground( + fg: Color, + bg: Color, + bold: bool, + theme: &Palette, + overrides: &Colors, + resolver: &mut crate::display::terminal_color::TerminalColorResolver, +) -> Color { + use crate::display::terminal_color::is_fixed_color; + let bg_base = rgb_from_rgba(theme.resolve(bg, overrides, false)); + let bg = resolver.resolve_background(bg_base, is_fixed_color(bg, overrides)); + // Resolve bold before contrast adjustment; Spec must retain that brightening. + let base = rgb_from_rgba(theme.resolve(fg, overrides, bold)); + let resolved = resolver.resolve_foreground( + base, + bg, + true, + rgb_from_rgba(theme.foreground), + rgb_from_rgba(theme.background), + ); + if resolved != base { Color::Spec(resolved.0) } else { fg } +} + pub(super) fn rgba_rgb(color: crate::display::color::Rgb, alpha: f32) -> Rgba { Rgba { r: f32::from(color.r) / 255.0, diff --git a/nebula_app/src/gpui_shell/terminal/element/color_tests.rs b/nebula_app/src/gpui_shell/terminal/element/color_tests.rs new file mode 100644 index 0000000..1278277 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/element/color_tests.rs @@ -0,0 +1,99 @@ +//! Replay the terminal features required by Codex message surfaces and stars. +//! Fixture colors follow codex rust-v0.154.0 tui/src/style.rs and +//! bottom_pane/chat_composer/sparkle.rs; this does not emulate a live Codex session. + +use nebula_terminal::event::VoidListener; +use nebula_terminal::render::{RenderSnapshot, SnapshotConfig}; +use nebula_terminal::term::color::Colors; +use nebula_terminal::term::test::TermSize; +use nebula_terminal::term::{Config, Term}; +use nebula_terminal::vte::ansi::{self, Color, NamedColor, Rgb}; + +use super::{Palette, resolve_app_colors_into, rgb_from_rgba}; +use crate::display::terminal_color::TerminalColorResolver; +use crate::gpui_shell::terminal::colors::from_ansi_rgb; +use crate::gpui_shell::theme::ResolvedTheme; + +fn palette(name: nebula_settings::ThemeName) -> Palette { + let resolved = ResolvedTheme::builtin(name, None); + let color = |[r, g, b]: [u8; 3]| from_ansi_rgb(Rgb { r, g, b }); + Palette { + background: color(resolved.terminal_background()), + foreground: color(resolved.terminal_foreground()), + ..Palette::default() + } +} + +fn capture(palette: &Palette) -> (RenderSnapshot, Rgb) { + let bg = palette.query_reply(NamedColor::Background as usize, &Colors::default()); + let fg = palette.query_reply(NamedColor::Foreground as usize, &Colors::default()); + let (top, alpha) = if palette.is_dark() { (255.0, 0.12) } else { (0.0, 0.04) }; + let blend = |channel: u8| (channel as f32 * (1.0 - alpha) + top * alpha).round() as u8; + let user = Rgb { r: blend(bg.r), g: blend(bg.g), b: blend(bg.b) }; + let bytes = format!( + "\x1b[48;2;{};{};{}m\x1b[38;2;{};{};{}m用户 message\x1b[K\r\n⠁⠂⠄⠈⠐⠠⡀⢀\x1b[K\r\n\x1b[0mAI reply", + user.r, user.g, user.b, fg.r, fg.g, fg.b, + ); + let mut term = Term::new(Config::default(), &TermSize::new(40, 4), VoidListener); + let mut parser: ansi::Processor = ansi::Processor::new(); + parser.advance(&mut term, bytes.as_bytes()); + (RenderSnapshot::capture(&term, &SnapshotConfig { rows: 4, cols: 40 }), user) +} + +#[test] +fn every_builtin_theme_keeps_message_surfaces_and_braille_stars() { + for name in nebula_settings::ThemeName::BUILTIN { + let palette = palette(name); + let (mut snapshot, user) = capture(&palette); + assert_ne!(from_ansi_rgb(user), palette.background, "{name:?}"); + let star_colors: Vec<_> = snapshot + .segments + .iter() + .filter(|seg| seg.row == 1) + .flat_map(|seg| seg.cells.iter().map(|cell| cell.fg)) + .collect(); + let mut resolver = TerminalColorResolver::default(); + resolve_app_colors_into(&mut snapshot, &palette, &Colors::default(), &mut resolver); + for row in [0, 1] { + let run = snapshot.bg_runs.iter().find(|run| run.row == row).unwrap(); + assert_eq!((run.start, run.end, run.color), (0, 40, Color::Spec(user)), "{name:?}"); + } + assert!( + snapshot.bg_runs.iter().all(|run| run.row != 2), + "{name:?}: AI reply acquired a message background" + ); + let stars: String = snapshot + .segments + .iter() + .filter(|seg| seg.row == 1) + .flat_map(|seg| seg.cells.iter().map(|cell| cell.text.as_str())) + .collect(); + assert_eq!(stars, "⠁⠂⠄⠈⠐⠠⡀⢀"); + let after: Vec<_> = snapshot + .segments + .iter() + .filter(|seg| seg.row == 1) + .flat_map(|seg| seg.cells.iter().map(|cell| cell.fg)) + .collect(); + assert_eq!(star_colors, after, "{name:?}: star colors were changed"); + } +} + +#[test] +fn message_background_remains_distinct_after_every_builtin_theme_switch() { + for from in nebula_settings::ThemeName::BUILTIN { + for to in nebula_settings::ThemeName::BUILTIN { + let original = palette(from); + let next = palette(to); + let (mut snapshot, _) = capture(&original); + let mut resolver = TerminalColorResolver::default(); + resolver + .theme_changed(rgb_from_rgba(original.background), rgb_from_rgba(next.background)); + resolve_app_colors_into(&mut snapshot, &next, &Colors::default(), &mut resolver); + let user = snapshot.bg_runs.iter().find(|run| run.row == 0).unwrap(); + let background = next.resolve(user.color, &Colors::default(), false); + assert_ne!(background, next.background, "{from:?} -> {to:?}"); + assert!(snapshot.bg_runs.iter().all(|run| run.row != 2)); + } + } +} diff --git a/nebula_app/src/gpui_shell/terminal/link_underline.rs b/nebula_app/src/gpui_shell/terminal/link_underline.rs new file mode 100644 index 0000000..6cecc43 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/link_underline.rs @@ -0,0 +1,132 @@ +//! Link dashes in physical grid coordinates, independent of glyph width/shaping. + +use gpui::{Bounds, Hsla, Pixels, Window, fill, point, px, size}; + +pub(super) fn paint(window: &mut Window, cell: Bounds, grid_left: Pixels, color: Hsla) { + let scale = window.scale_factor().max(0.5); + let y = (cell.bottom().as_f32() * scale).round(); + let thickness = scale.round().max(1.0); + for (left, right) in + dash_segments(cell.left().as_f32(), cell.right().as_f32(), grid_left.as_f32(), scale) + { + window.paint_quad(fill( + Bounds::new( + point(px(left / scale), px((y - thickness) / scale)), + size(px((right - left) / scale), px(thickness / scale)), + ), + color, + )); + } +} + +fn dash_segments( + left: f32, + right: f32, + anchor: f32, + scale: f32, +) -> impl Iterator { + let left = (left * scale).round(); + let right = (right * scale).round(); + let anchor = (anchor * scale).round(); + let dash = (3.0 * scale).round().max(1.0); + let period = dash + (2.0 * scale).round().max(1.0); + let mut x = anchor + ((left - anchor) / period).floor() * period; + std::iter::from_fn(move || { + while x < right { + let start = x.max(left); + let end = (x + dash).min(right); + x += period; + if start < end { + return Some((start, end)); + } + } + None + }) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + use crate::config::UiConfig; + use crate::gpui_shell::terminal::osc_links::dashed_cells; + use nebula_terminal::event::VoidListener; + use nebula_terminal::term::test::TermSize; + use nebula_terminal::term::{Config, Term}; + use nebula_terminal::vte::ansi; + + #[test] + #[ignore = "informational hot-path measurement; no machine-specific timing gate"] + fn measure_visible_link_decoration_work() { + for (name, linked, text) in + [("plain", false, "x"), ("ascii", true, "x"), ("cjk", true, "中")] + { + let mut term = Term::new(Config::default(), &TermSize::new(120, 40), VoidListener); + let mut parser: ansi::Processor = ansi::Processor::new(); + let text = text.repeat(if name == "cjk" { 50 } else { 100 }); + let row = if linked { + format!("\x1b]8;;file:///fixture\x1b\\{text}\x1b]8;;\x1b\\\r\n") + } else { + format!("{text}\r\n") + }; + parser.advance(&mut term, row.repeat(35).as_bytes()); + let config = UiConfig::default(); + let started = std::time::Instant::now(); + let mut last_cells = 0; + for _ in 0..200 { + let cells = dashed_cells(&term, &config, 40, 120); + last_cells = cells.len(); + let segments: usize = cells + .keys() + .map(|&(_, col)| { + dash_segments( + 2.3 + f32::from(col) * 8.4, + 2.3 + f32::from(col + 1) * 8.4, + 2.3, + 1.25, + ) + .count() + }) + .sum(); + std::hint::black_box((cells, segments)); + } + use std::io::Write as _; + let _ = writeln!( + std::io::stderr().lock(), + "{name}: 120x40 grid, {last_cells} linked columns, {:.1} us/pass (capture + dash geometry, no GPU)", + started.elapsed().as_secs_f64() * 1_000_000.0 / 200.0 + ); + } + } + + #[test] + fn real_osc8_grid_covers_cjk_spacers_and_spaces_with_one_dash_phase() { + let mut term = Term::new(Config::default(), &TermSize::new(40, 2), VoidListener); + let mut parser: ansi::Processor = ansi::Processor::new(); + parser.advance( + &mut term, + "\x1b]8;;file:///tmp/example\x1b\\A开始 菜单Z\x1b]8;;\x1b\\".as_bytes(), + ); + let cells = dashed_cells(&term, &UiConfig::default(), 2, 40); + assert_eq!(cells.len(), 11); + assert!((0..11).all(|col| cells.contains_key(&(0, col)))); + for scale in [1.0, 1.25, 1.5, 2.0] { + for width in [7.0, 8.4, 9.5, 12.0] { + let anchor = 2.3; + let pixels = |left, right| { + dash_segments(left, right, anchor, scale) + .flat_map(|(left, right)| left as i32..right as i32) + }; + let actual: BTreeSet<_> = cells + .keys() + .flat_map(|&(_, col)| { + pixels(anchor + f32::from(col) * width, anchor + f32::from(col + 1) * width) + }) + .collect(); + let expected: BTreeSet<_> = pixels(anchor, anchor + 11.0 * width).collect(); + assert_eq!(actual, expected, "scale={scale}, width={width}"); + } + } + } +} diff --git a/nebula_app/src/gpui_shell/terminal/mod.rs b/nebula_app/src/gpui_shell/terminal/mod.rs index 6bf0f85..012f095 100644 --- a/nebula_app/src/gpui_shell/terminal/mod.rs +++ b/nebula_app/src/gpui_shell/terminal/mod.rs @@ -12,6 +12,7 @@ mod event_mailbox; mod inline_image; pub mod keymap; mod ligatures; +mod link_underline; pub mod math_overlay; mod molecule_overlay; pub mod mouse_protocol; diff --git a/nebula_app/src/gpui_shell/terminal/osc_links.rs b/nebula_app/src/gpui_shell/terminal/osc_links.rs index 94a3912..d825407 100644 --- a/nebula_app/src/gpui_shell/terminal/osc_links.rs +++ b/nebula_app/src/gpui_shell/terminal/osc_links.rs @@ -3,13 +3,15 @@ //! 匹配与动作全部复用旧壳 `display::hint` + `file_uri` + `daemon`,这里只做 //! 视口坐标、GPUI 修饰键和打开入口。 -use std::collections::HashSet; +use std::collections::HashMap; use std::sync::Arc; use gpui::{App, ClipboardItem, Window}; use nebula_terminal::event::EventListener; use nebula_terminal::index::Point; +use nebula_terminal::term::cell::Flags; use nebula_terminal::term::{Term, point_to_viewport_from}; +use nebula_terminal::vte::ansi::Color; use unicode_width::UnicodeWidthChar; use winit::keyboard::ModifiersState; @@ -48,25 +50,42 @@ pub(super) fn winit_mouse_mods(mods: &gpui::Modifiers) -> ModifiersState { } /// 可见可点范围(OSC 8 + 正则 URL)映射到当前视口格子,供虚线下划线使用。 +pub(super) struct LinkCell { + pub fg: Color, + pub bg: Color, + pub bold: bool, +} + +pub(super) type LinkCells = HashMap<(u16, u16), LinkCell>; + pub(super) fn dashed_cells( term: &Term, config: &UiConfig, rows: usize, cols: usize, -) -> HashSet<(u16, u16)> { +) -> LinkCells { let matches = hint::visible_clickable_matches(term, config); if matches.is_empty() { - return HashSet::new(); + return HashMap::new(); } let origin = term.viewport_origin_for(rows); - let mut cells = HashSet::new(); + let mut cells = HashMap::new(); for indexed in term.grid().display_iter() { - if !matches.iter().any(|bounds| bounds.contains(&indexed.point)) { + if indexed.flags.intersects(Flags::HIDDEN | Flags::LEADING_WIDE_CHAR_SPACER) + || !matches.iter().any(|bounds| bounds.contains(&indexed.point)) + { continue; } let Some(vp) = point_to_viewport_from(origin, indexed.point) else { continue }; if vp.line < rows && vp.column.0 < cols { - cells.insert((vp.line as u16, vp.column.0 as u16)); + let (mut fg, mut bg) = (indexed.fg, indexed.bg); + if indexed.flags.contains(Flags::INVERSE) { + std::mem::swap(&mut fg, &mut bg); + } + cells.insert( + (vp.line as u16, vp.column.0 as u16), + LinkCell { fg, bg, bold: indexed.flags.contains(Flags::BOLD) }, + ); } } cells @@ -110,12 +129,61 @@ pub(super) fn open_hint_match( hint: &HintMatch, text: &str, cwd: Option<&std::path::Path>, + launch: &crate::session::LaunchSession, window: &mut Window, cx: &mut App, ) { + let target = (hint.hyperlink().is_none() + && hint.action() == &HintAction::Command(default_hint_command())) + .then(|| wsl_absolute_target(text, launch)) + .flatten(); + let text = target.as_deref().unwrap_or(text); dispatch_hint_action(hint.action(), hint.hyperlink().is_some(), text, cwd, window, cx); } +fn wsl_absolute_target(text: &str, launch: &crate::session::LaunchSession) -> Option { + if !text.starts_with('/') || text.starts_with("//") { + return None; + } + let (program, args) = match launch { + crate::session::LaunchSession::Shell { program, args, .. } => (program, args), + crate::session::LaunchSession::Profile { command, args, .. } => (command, args), + _ => return None, + }; + let distro = crate::shell_detect::wsl_launch_distro(program, args)?; + Some(crate::shell_detect::wsl_unc_path(distro, text).to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod path_tests { + use super::*; + + #[test] + fn absolute_prompt_paths_use_the_owning_wsl_distribution() { + let launch = crate::session::LaunchSession::Shell { + name: "Debian".into(), + program: "wsl.exe".into(), + args: vec!["-d".into(), "Debian".into()], + }; + assert_eq!( + wsl_absolute_target("/mnt/d/project", &launch).as_deref(), + Some(r"\\wsl.localhost\Debian\mnt\d\project") + ); + assert!(wsl_absolute_target("//example.com/file", &launch).is_none()); + assert!(wsl_absolute_target("https://example.com", &launch).is_none()); + assert!( + wsl_absolute_target( + "/home/user", + &crate::session::LaunchSession::Ssh { host: "remote".into() } + ) + .is_none() + ); + assert!( + wsl_absolute_target("/home/user", &crate::session::LaunchSession::Default).is_none() + ); + } +} + fn dispatch_hint_action( action: &HintAction, hyperlink: bool, diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index 25367d1..4a6bcf3 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -178,7 +178,14 @@ impl TerminalView { }; let Some(text) = text else { return }; let cwd = self.local_cwd(); - super::super::osc_links::open_hint_match(&hover.hint, &text, cwd.as_deref(), window, cx); + super::super::osc_links::open_hint_match( + &hover.hint, + &text, + cwd.as_deref(), + &self.session_launch, + window, + cx, + ); } /// 应用是否接管了鼠标(vim/htop 等)。Shift 按住时强制旁路——这是 diff --git a/scripts/tests/user-feedback-regressions.md b/scripts/tests/user-feedback-regressions.md new file mode 100644 index 0000000..fbf13ac --- /dev/null +++ b/scripts/tests/user-feedback-regressions.md @@ -0,0 +1,59 @@ +# 用户反馈回归测试清单 + +本清单覆盖 2026-09-23 汇总的 Hook、更新、窗口材质、链接、Codex 显示与 +Windows 右键菜单反馈。它是验收合同,不表示下列功能已经全部修复。 +`待验证`、`等待日志`、`部分覆盖` 均不得作为 Issue 已解决的依据。 + +## 执行与证据 + +- 每轮记录源码提交、未提交补丁、产品版本、Windows build、DPI、主题、字体、 + Shell 与 Codex 版本。记录实际命令、通过/失败数量及截图或日志位置。 +- 自动回归使用实际生产函数、真实终端网格或进程夹具;不能只检查源码中出现 + 某个字符串。原生截图不替代行为测试,编译成功也不替代视觉验收。 +- 对修复先验证旧实现失败,再验证新实现通过;保留相邻功能的反例。 +- 浅色/深色、100%/125%/150%/200% DPI、宽/窄窗口、分屏、滚动和主题热切换 + 按条目覆盖。平台条件导致无法执行的项必须注明,不计入通过数量。 +- 实机操作使用隔离配置与测试窗口。安装/注册表夹具只修改其临时测试根, + 不关闭用户会话,不覆盖正式安装,不触碰用户 Hook、凭据和发行版配置。 +- 仅关闭已有代码与验收证据覆盖的问题单;部分修复说明剩余范围。 + 发布版本尚未包含修复时必须说明交付状态,不把本地通过写成已发布。 + +## 回归项目 + +| ID | 问题与来源 | 自动回归 / 通过条件 | 实机验收 / 反例 | 当前证据 | +| --- | --- | --- | --- | --- | +| HOOK-255 | [#255](https://github.com/Kuddev/pebrel/issues/255):Windows Codex Hook 被 PowerShell 解析为字符串而未执行 | 生成命令实际执行;`codex`、`--hooks=full/turns` 和 stdin JSON 完整传入;带空格、单引号、`$`、反引号的路径保持字面值 | Windows PowerShell 5.1 与 PowerShell 7;Codex 0.155.1/0.156.0;新会话、工具调用、提问、停止都无 ParserError | Windows 5.1 进程夹具通过;完整 Codex/PowerShell 7 会话待验收 | +| HOOK-LIFETIME | #258 前置故障:关闭 Pebrel 后 Hook 仍占用安装文件 | 保持 stdin 打开、连接后不读取命名管道时,真实 Hook 子进程仍在期限内以 0 退出;Cursor 继续允许提交;用户原有 notifier 仍执行 | 已运行的旧版 Hook 不会自动获得新期限;分别验证新启动的 native Hook 与旧进程恢复步骤 | 7 项单元 + 5 项真实进程回归通过 | +| HOOK-MIGRATE | 更新后旧 `hooks.json` / 归属标记仍含失效命令 | 旧受管命令迁移;重复安装幂等;保留 notify、第三方 Hook、显式禁用及用户编辑;卸载只移除自有条目 | 重启 Pebrel 后自动修复;用户修改过的冲突条目不能静默覆盖 | `ai_hook::` 120 通过、1 项既有忽略;后续改动需重跑相关项 | +| UPDATE-403 | 手动检查更新返回 HTTP 403 | 公共 Release 无需用户 token;覆盖 403/429、正常 JSON、其他错误、代理、受限响应;下载仍须官方 URL、精确资产名和 SHA-256 | 同一网络直连/代理、启动检查/手动检查;离线不得假报最新或成功 | **等待日志,按用户要求暂停**;未验收、不结项 | +| UPDATE-EXIT5 | 安装器 `exit code 5`,当前/最新版本显示相同 | 失败不得标成功;保留原快照与安装日志;已退出进程、安装失败、部分替换、相同版本、校验失败分别验证 | 基于反馈者 `installer.log` 重现;核对原版本、目标版本、实际 EXE;不能把 Inno 退出码 5 当作 Win32 拒绝访问码 | 后续 #258 已提供修复安装成功日志;无限等待的 Hook 已补期限,真实锁定故障仍需与旧版本进程区分 | +| UPDATE-258 | [#258](https://github.com/Kuddev/pebrel/issues/258):同版本修复成功却因主程序哈希不变报错 | 同版本主程序字节不变、Hook 文件修复后应成功;安装器失败或目标版本仍错误必须失败 | 同版本重装、首次升级部分成功后的修复;日志、实际版本和结果提示一致;原会话快照保留 | 10 项原生 handoff 场景通过,包含 `repair-identical` 与 `upgrade-noop` 正反例 | +| BLUR-236-A | [#236](https://github.com/Kuddev/pebrel/issues/236):Acrylic 在屏幕边缘透出背景内容 | 切换材质/透明度不重复创建资源;新窗口、关闭、失焦、最大化/还原、队列退出及运行库失败路径 | 0% 与非零不透明度;明显文字/色块背景;左右/上下屏幕边缘、最大化、任务栏恢复、混合 DPI;失焦不出现不透明灰底 | [PR #250](https://github.com/Kuddev/pebrel/pull/250) 尚未合并;其跨平台 CI 通过不等于本轮实机验收 | +| BLUR-236-B | #236 的 Aero 与 Acrylic 兼容回退范围 | Windows 10、Win11 22H2+、缺失/不匹配运行库与失败挂接均可正常启动/切换/退出 | Aero 需单独验收;缺少受支持 Windows App Runtime 时旧边缘现象不能标为已解决 | #250 仅覆盖受支持 Acrylic;**Aero 和回退范围仍开放** | +| LINK-CJK | OSC 8 中文/全角字符的虚线只画半字、错位 | 从真实 VT 输出构建网格,中文两格、空格、ASCII、组合字符均按列覆盖;逐格绘制与连续区间的物理像素集合相同 | `A开始 菜单Z`、纯中文、混排;DPI/分屏/窄窗/换行;不随字体回补或字形分段重置虚线 | 真实 OSC 8 网格与 16 种 DPI/格宽组合像素覆盖回归通过;原生 Nord / Paper 混排截图已核对 | +| LINK-PATH | 英文路径下划线断开,带空格路径只匹配前半段 | 连续虚线相位;引号包围的盘符/UNC/`~/` 路径完整匹配;hover、点击使用同一边界;普通正文不误连 | `"C:\Program Files\Example Tools\probe.exe"`、中文与括号目录、相邻命令参数、路径换行 | 8 项 hint 测试与 5 项配置解析测试通过;原生带空格路径截图已核对 | +| LINK-PROMPT | WSL `user@host:~/.codex$` 吞入 `$`;绝对路径提示符没有下划线 | `~/` 与 `/mnt/d/...` 提示符中的 `$`/`#` 不可点;真实文件名 `~/cost$`、URL 尾部 `$`、路径内部 `$` 保留;下划线与命中边界一致 | Bash/WSL 彩色提示符、root 提示符、输入命令后、换行;OSC 8 显式 URI 不被启发式改写 | 正反匹配/命中回归通过;绝对路径打开应使用所属 WSL 发行版,不得套用宿主目录或 SSH 目标 | +| CODEX-SPARKLE | 对照终端 中可见的 Codex 星点在 Pebrel 不显示 | 核对真彩色能力、OSC 10/11 回答、盲文点字与前/背景色;真实 PTY 录制/回放;关闭动画时不强制产生动画 | 相同 Codex/模型/设置,对照 WT;启动、输入、弹窗遮挡、焦点、主题切换;不能伪造 `WT_SESSION` 来冒充 WT | 完整 Windows 子环境漏真彩色声明已修复;8 项环境回归及 15 主题 VT 回放通过;真实产品星点字形/背景截图已核对;真实 Codex 动画仍需验收 | +| CODEX-MESSAGE | **所有主题**的 Codex 用户消息与 AI 回复背景缺少区分 | 保留/正确映射应用给出的用户消息背景;未提供背景时不得凭屏幕文字猜测角色;消息文本对比度不退化 | 遍历主题注册表中的全部内置主题;输入框与已发送的单/多行、中英混排消息;深浅切换、滚动回看;用户消息应有可辨认浅色背景区 | 15 个内置主题 + 225 种主题切换的背景保留回归通过;真实产品 Nord / Paper 的背景和星点截图已核对;其余主题有自动回放覆盖,仍需原生视觉抽查 | +| SHELL-MENU | 多个 WSL 发行版把资源管理器右键菜单铺满 | 保留一个普通“在 Pebrel 中打开”;其余发行版集中到一个级联菜单;零/一/多个发行版、重复安装、移除发行版、卸载与旧项迁移 | 文件夹对象/文件夹空白处;中文/空格/盘根路径;点击普通项与每个子项验证 Shell 和 cwd;保留第三方/其他安装拥有的键 | 已实现;真实 Inno 夹具累计 117 项通过,完整安装器编译通过;正式 Explorer 点击验收仍需进行 | + +## 当前测试入口 + +```powershell +cargo test --locked -p nebula_hook +cargo test --locked -p nebula --bin pebrel --features gpui-shell platform::environment::tests +cargo test --locked -p nebula --bin pebrel --features gpui-shell ai_hook:: +cargo test --locked -p nebula --bin pebrel --features gpui-shell display::hint::tests +cargo test --locked -p nebula --bin pebrel --features gpui-shell config::ui_config::tests +cargo test --locked -p nebula --bin pebrel --features gpui-shell gpui_shell::terminal::link_underline::tests +cargo test --locked -p nebula --bin pebrel --features gpui-shell gpui_shell::terminal::element::tests +cargo test --locked -p nebula --bin pebrel --features gpui-shell display::terminal_color::tests +``` + +安装器既有入口为 `scripts/tests/installer.tests.ps1`、 +`scripts/tests/installer-migration.tests.ps1`、`scripts/tests/test_update_handoff.ps1`。 +菜单夹具已扩展零/一/多个发行版及所有权保护;handoff 已扩展同版本修复正例与错误版本反例。它们不代替正式安装或 Explorer 点击验收。 + +修改完成后还需按 `CONTRIBUTING.md` 执行架构、格式、受影响模块与真实产品检查; +架构门禁使用实际目标分支基线。新增门禁或 GitHub 强制保护需单独验证, +本清单不宣称启用了服务端保护。 From 06e98f13450c7a57393016e8d1c45c4769801090 Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 13:30:33 +0800 Subject: [PATCH 04/12] fix(windows): preserve truecolor capabilities in refreshed pane environments --- .../2026-09-23-local-color-capabilities.md | 62 +++++++++++ nebula_app/src/platform/environment.rs | 105 ++++++++++++++++-- 2 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 architecture/notes/nebula_app/platform/2026-09-23-local-color-capabilities.md diff --git a/architecture/notes/nebula_app/platform/2026-09-23-local-color-capabilities.md b/architecture/notes/nebula_app/platform/2026-09-23-local-color-capabilities.md new file mode 100644 index 0000000..659ca41 --- /dev/null +++ b/architecture/notes/nebula_app/platform/2026-09-23-local-color-capabilities.md @@ -0,0 +1,62 @@ +# Color capabilities belong to the local child environment + +## Status + +Implemented; pending review. + +## Context + +Windows users report that Codex lacks its sparse composer stars and user-message +backgrounds in Pebrel while they appear in the reference terminal. The same distinction +must work across themes without identifying message roles from screen text. + +## Evidence + +GPUI `local_options` refreshes a complete Windows environment before `spawn` +calls `tty::setup_env()`. The environment cache preserves its earlier snapshot; +changing the parent environment afterward cannot insert `COLORTERM` into the +already complete child environment. An Explorer launch need not have terminal +capability variables inherited from another terminal. + +Codex's `rust-v0.154.0` `tui/src/terminal_palette.rs` returns the default color +for ANSI-16/unknown color levels. `style.rs` uses that result for user-message +backgrounds. `bottom_pane/chat_composer/sparkle.rs` requires truecolor and queried +foreground/background colors. This explains a shared failure path; settings, +model choice and Codex version still control whether its animation runs. + +## Decision + +After refreshing a local Windows environment, fill missing `TERM` with +`xterm-256color` and missing `COLORTERM` with `truecolor`. Preserve explicit +case-insensitive overrides and `NO_COLOR`/`FORCE_COLOR`. Keep the actual Pebrel +terminal identity. Existing OSC color-query and rendering paths remain authoritative. + +## Rejected alternatives + +- Pretending to be the reference terminal through `WT_SESSION` misstates identity. +- Recoloring arbitrary terminal rows as user messages would invent application + semantics and damage ordinary command output. +- Hardcoding a Nord-only fix leaves other themes and applications affected. + +## Consequences + +New local Windows panes advertise implemented capabilities independently of their +launching parent. Already running shells retain their inherited environment. +Providers can still intentionally suppress colors or animations. The two-second +Hook lifetime fix is unrelated to this environment declaration. + +## Validation + +Eight platform-environment tests pass, including an actual child launched with +an explicitly complete environment. VT replay preserves message surfaces and +Braille stars for all fifteen built-in themes, plus all 225 theme-switch pairs. +These are capability/rendering tests, not a full live Codex animation acceptance. + +## Supersedes + +None. + +## Revisit when + +Terminal capability defaults move into a shared per-child environment builder, +or the Windows environment refresh no longer creates a complete snapshot. diff --git a/nebula_app/src/platform/environment.rs b/nebula_app/src/platform/environment.rs index 66070a1..e309af1 100644 --- a/nebula_app/src/platform/environment.rs +++ b/nebula_app/src/platform/environment.rs @@ -9,7 +9,7 @@ pub(crate) fn prepare_local_pty(options: &mut nebula_terminal::tty::Options) { } let inherited_override = !options.env_is_complete && std::env::var_os(GROK_LEGACY_CONSOLE).is_some(); - apply_windows_terminal_defaults(options, inherited_override); + apply_local_console_defaults(options, inherited_override); } #[cfg(not(windows))] let _ = options; @@ -19,10 +19,20 @@ pub(crate) fn prepare_local_pty(options: &mut nebula_terminal::tty::Options) { const GROK_LEGACY_CONSOLE: &str = "GROK_FORCE_LEGACY_CONSOLE"; #[cfg(windows)] -fn apply_windows_terminal_defaults( +fn apply_local_console_defaults( options: &mut nebula_terminal::tty::Options, inherited_override: bool, ) { + // The refreshed, complete environment is captured before the GPUI spawn + // calls tty::setup_env(). Updating the parent later cannot change that + // snapshot. Advertise our actual capabilities in the child environment, + // including Explorer launches with no parent terminal. Keep explicit values. + for (name, value) in [("TERM", "xterm-256color"), ("COLORTERM", "truecolor")] { + if !options.env.keys().any(|key| key.eq_ignore_ascii_case(name)) { + let inherited = if options.env_is_complete { None } else { std::env::var(name).ok() }; + options.env.insert(name.to_owned(), inherited.unwrap_or_else(|| value.to_owned())); + } + } // Grok 1.0.25 treats unknown Windows terminal names as legacy consoles and // omits its Braille logo. Keep our real identity and use its capability override. // Remove this default when Grok recognizes Pebrel's terminal capabilities. @@ -94,13 +104,13 @@ mod tests { env_is_complete: true, ..Default::default() }; - super::apply_windows_terminal_defaults(&mut options, false); + super::apply_local_console_defaults(&mut options, false); assert_eq!(options.env[super::GROK_LEGACY_CONSOLE], "0"); assert_eq!(options.env["TERM_PROGRAM"], "pebrel"); assert_eq!(options.env["WSLENV"], "KEEP/p"); assert!(!options.env.contains_key("WT_SESSION")); let once = options.clone(); - super::apply_windows_terminal_defaults(&mut options, false); + super::apply_local_console_defaults(&mut options, false); assert_eq!(options, once); } @@ -110,12 +120,18 @@ mod tests { for name in [super::GROK_LEGACY_CONSOLE, "grok_force_legacy_console"] { for value in ["1", "true", "0", "false", ""] { let mut options = nebula_terminal::tty::Options { - env: [(name.to_owned(), value.to_owned())].into_iter().collect(), + env: [ + (name.to_owned(), value.to_owned()), + ("TERM".to_owned(), "xterm-256color".to_owned()), + ("COLORTERM".to_owned(), "truecolor".to_owned()), + ] + .into_iter() + .collect(), env_is_complete: true, ..Default::default() }; let original = options.clone(); - super::apply_windows_terminal_defaults(&mut options, false); + super::apply_local_console_defaults(&mut options, false); assert_eq!(options, original); } } @@ -125,21 +141,92 @@ mod tests { #[test] fn inherited_grok_override_only_applies_to_incomplete_environments() { let mut options = nebula_terminal::tty::Options::default(); - super::apply_windows_terminal_defaults(&mut options, true); + super::apply_local_console_defaults(&mut options, true); assert!(!options.env.contains_key(super::GROK_LEGACY_CONSOLE)); // A successful refresh can remove a stale value from the parent's registry // snapshot. Do not resurrect it when the new complete environment omits it. options.env_is_complete = true; - super::apply_windows_terminal_defaults(&mut options, true); + super::apply_local_console_defaults(&mut options, true); assert_eq!(options.env[super::GROK_LEGACY_CONSOLE], "0"); let mut fallback = nebula_terminal::tty::Options::default(); - super::apply_windows_terminal_defaults(&mut fallback, false); + super::apply_local_console_defaults(&mut fallback, false); assert_eq!(fallback.env[super::GROK_LEGACY_CONSOLE], "0"); assert!(!fallback.env_is_complete); } + #[cfg(windows)] + #[test] + fn complete_explorer_environment_advertises_truecolor_to_the_child_process() { + let mut options = nebula_terminal::tty::Options { + env: std::env::vars() + .filter(|(name, _)| { + !["TERM", "COLORTERM", "WT_SESSION", "TERM_PROGRAM"] + .iter() + .any(|key| name.eq_ignore_ascii_case(key)) + }) + .collect(), + env_is_complete: true, + ..Default::default() + }; + options.env.insert("TERM_PROGRAM".into(), "pebrel".into()); + super::apply_local_console_defaults(&mut options, false); + assert_eq!(options.env["TERM"], "xterm-256color"); + assert_eq!(options.env["COLORTERM"], "truecolor"); + assert!(!options.env.contains_key("WT_SESSION")); + // Match the complete-environment child launch, independent of the test + // runner's terminal. Read the variables from the actual child. + let system = std::env::var_os("SystemRoot").unwrap(); + let command = + std::path::Path::new(&system).join("System32/WindowsPowerShell/v1.0/powershell.exe"); + let output = std::process::Command::new(command) + .args([ + "-NoProfile", + "-Command", + "[Console]::WriteLine(\"$env:TERM $env:COLORTERM $env:TERM_PROGRAM\")", + ]) + .env_clear() + .env("SystemRoot", system) + .envs(&options.env) + .output() + .unwrap(); + assert!( + output.status.success(), + "status={}, stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8(output.stdout).unwrap().trim(), + "xterm-256color truecolor pebrel" + ); + } + + #[cfg(windows)] + #[test] + fn color_capability_defaults_preserve_explicit_overrides() { + let mut options = nebula_terminal::tty::Options { + env: [ + ("term", "dumb"), + ("ColorTerm", "24bit"), + ("NO_COLOR", "1"), + ("FORCE_COLOR", "0"), + ] + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect(), + env_is_complete: true, + ..Default::default() + }; + super::apply_local_console_defaults(&mut options, false); + assert_eq!(options.env["term"], "dumb"); + assert_eq!(options.env["ColorTerm"], "24bit"); + assert_eq!(options.env["NO_COLOR"], "1"); + assert_eq!(options.env["FORCE_COLOR"], "0"); + assert!(!options.env.contains_key("TERM") && !options.env.contains_key("COLORTERM")); + } + #[cfg(windows)] #[test] fn refreshed_local_terminal_preserves_the_pane_grok_override() { From e054b09771c12ec7658ff8f9cf54bbced39d958a Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 13:33:14 +0800 Subject: [PATCH 05/12] fix(installer): group WSL distributions under one context submenu --- .../2026-09-23-wsl-context-menu-cascade.md | 59 +++++++ scripts/installer-migration.iss | 146 ++++++++++++------ scripts/installer.iss | 4 +- scripts/tests/installer-migration-fixture.iss | 69 ++++++++- 4 files changed, 227 insertions(+), 51 deletions(-) create mode 100644 architecture/notes/packaging/2026-09-23-wsl-context-menu-cascade.md diff --git a/architecture/notes/packaging/2026-09-23-wsl-context-menu-cascade.md b/architecture/notes/packaging/2026-09-23-wsl-context-menu-cascade.md new file mode 100644 index 0000000..69343a5 --- /dev/null +++ b/architecture/notes/packaging/2026-09-23-wsl-context-menu-cascade.md @@ -0,0 +1,59 @@ +# Group WSL launch entries under one Explorer submenu + +## Status + +Implemented; pending review. + +## Context + +One top-level context item per WSL distribution crowds Explorer menus. The user +requested one ordinary Pebrel entry and one expandable entry for distribution +choices, for both selected directories and directory backgrounds. + +## Evidence + +The previous installer registered `PebrelWsl` directly under each Explorer +shell root. Those entries remain registered after an upgrade unless explicitly +migrated. WSL distribution enumeration already excludes Docker plumbing entries. + +## Decision + +Keep the ordinary entry. Register a static `PebrelWslMenu` cascade with an empty +`SubCommands` value and its distribution verbs under `shell`. Record its executable +owner and validate child commands before replacing or removing the subtree. +Migrate only flat entries whose command invokes the exact owned executable. + +Preserve `%1` for selected directories, `%V` for backgrounds, and the existing +`--shell` before `--working-directory` order. No distributions means no empty +submenu. Conflicting/edited subtrees and other installations remain untouched. + +## Rejected alternatives + +- Removing distribution choices reduces existing launch functionality. +- Deleting every key with the product prefix can remove another installation or + user customization. +- Installing a new Explorer extension adds deployment and lifetime requirements + unnecessary for this static cascade. + +## Consequences + +Registration still reflects distributions present at installation time. Existing +menus migrate when the new installer runs; changing this source does not modify +the user's live registry. Conflicts fail visibly rather than overwrite unknown keys. + +## Validation + +The real Inno fixture uses an isolated HKCU test subtree and passes 117 migration +checks in total. New coverage includes both roots, zero/one/three distributions, +Unicode/space-containing names, repeat registration, stale-entry removal, edited +subtrees and foreign ownership. The complete installer compiles without executing +an installation. Explorer presentation/click acceptance remains separate. + +## Supersedes + +The installer's flat WSL verb layout; the ordinary Pebrel verb is retained. + +## Revisit when + +Distribution registration must update outside installation, or Explorer requires +a different supported menu registration mechanism. diff --git a/scripts/installer-migration.iss b/scripts/installer-migration.iss index 033dd7d..c281776 100644 --- a/scripts/installer-migration.iss +++ b/scripts/installer-migration.iss @@ -277,13 +277,13 @@ end; { —— 按 WSL 发行版注册的右键项 —— - 「在 Pebrel 中打开(Ubuntu)」这类菜单项按**安装那一刻**机器上的发行版动态生成: + WSL 发行版子菜单按**安装那一刻**机器上的发行版动态生成: `Lxss` 里的 `DistributionName` 是唯一真相,枚举口径与 Rust 侧 `shell_detect::find_wsl_distros` 一致(跳过 `docker-desktop*` 这类 plumbing 发行版)。 走 `[Code]` 而不是 `[Registry]` 的原因只有一个:键的条数取决于机器上有几个发行版。 代价是这些键没有 `uninsdeletekey`,卸载由 `RemoveOwnedWslContextMenus` 按名字前缀 - 加命令串里的可执行文件与 `--shell` 认领后删除。 } + 加精确命令与级联菜单归属标记认领后删除。 } const LxssRoot = 'Software\Microsoft\Windows\CurrentVersion\Lxss'; @@ -308,67 +308,117 @@ begin SetArrayLength(Result, Count); end; -{ 删掉本安装器写下的 WSL 右键项:键名以 `PebrelWsl` 开头、命令串里同时有我们的 - 可执行文件和 `--shell`,两条都满足才算我们写的。发行版后来被移除也不会留下 - 点不动的死项。 } +{ WSL 右键项按精确可执行文件前缀归属;级联菜单还校验所有子项,保留用户编辑。 + 发行版移除后,下次安装重建菜单时不留下旧项。 } +function IsOwnedWslCommand(Command, Executable: string): Boolean; +var + Prefix: string; +begin + Prefix := '"' + Executable + '" --gpui --shell "wsl:'; + Result := CompareText(Copy(Command, 1, Length(Prefix)), Prefix) = 0; +end; + +function IsOwnedWslMenu(Key, Executable: string): Boolean; +var + Owner, Command: string; + Children, Subkeys: TArrayOfString; + Index: Integer; +begin + Result := False; + if not RegQueryStringValue(HKCU, Key, 'PebrelOwner', Owner) or + (CompareText(Owner, Executable) <> 0) then + Exit; + if not RegGetSubkeyNames(HKCU, Key, Subkeys) then + Exit; + for Index := 0 to GetArrayLength(Subkeys) - 1 do + if CompareText(Subkeys[Index], 'shell') <> 0 then + Exit; + if GetArrayLength(Subkeys) > 0 then begin + if not RegGetSubkeyNames(HKCU, Key + '\shell', Children) then + Exit; + for Index := 0 to GetArrayLength(Children) - 1 do begin + if not RegQueryStringValue(HKCU, Key + '\shell\' + Children[Index] + '\command', '', Command) or + not IsOwnedWslCommand(Command, Executable) then + Exit; + end; + end; + Result := True; +end; + +procedure RemoveOwnedWslContextMenusAt(Root, Executable: string); +var + Key, Command: string; + Names: TArrayOfString; + NameIndex: Integer; +begin + if RegGetSubkeyNames(HKCU, Root, Names) then + for NameIndex := 0 to GetArrayLength(Names) - 1 do + if Pos('PebrelWsl', Names[NameIndex]) = 1 then begin + Key := Root + '\' + Names[NameIndex]; + Command := ''; + if IsOwnedWslMenu(Key, Executable) or + (RegQueryStringValue(HKCU, Key + '\command', '', Command) and + IsOwnedWslCommand(Command, Executable)) then + if not RegDeleteKeyIncludingSubkeys(HKCU, Key) then + RaiseException('Unable to remove an owned WSL context menu: ' + Key); + end; +end; + procedure RemoveOwnedWslContextMenus; var Roots: array[0..1] of string; - Root, Key, Command: string; - Names: TArrayOfString; - Index, NameIndex: Integer; + Index: Integer; begin Roots[0] := 'Software\Classes\Directory\shell'; Roots[1] := 'Software\Classes\Directory\Background\shell'; - for Index := 0 to 1 do begin - Root := Roots[Index]; - if RegGetSubkeyNames(HKCU, Root, Names) then - for NameIndex := 0 to GetArrayLength(Names) - 1 do - if Pos('PebrelWsl', Names[NameIndex]) = 1 then begin - Key := Root + '\' + Names[NameIndex]; - Command := ''; - if RegQueryStringValue(HKCU, Key + '\command', '', Command) and - (Pos(ExpandConstant('{app}\pebrel.exe'), Command) > 0) and - (Pos('--shell', Command) > 0) then - if not RegDeleteKeyIncludingSubkeys(HKCU, Key) then - RaiseException('Unable to remove an owned WSL context menu: ' + Key); - end; + for Index := 0 to 1 do + RemoveOwnedWslContextMenusAt(Roots[Index], ExpandConstant('{app}\pebrel.exe')); +end; + +procedure RegisterWslContextMenuAt(Root, Executable, DirectoryArgument: string; + Distros: TArrayOfString); +var + Index: Integer; + Distro, Verb, Command, Key, Menu: string; +begin + Menu := Root + '\PebrelWslMenu'; + { Never overwrite an unknown installation or an edited submenu. } + if RegKeyExists(HKCU, Menu) and not IsOwnedWslMenu(Menu, Executable) then + RaiseException('An unowned or edited WSL context menu was preserved: ' + Menu); + RemoveOwnedWslContextMenusAt(Root, Executable); + if GetArrayLength(Distros) = 0 then + Exit; + if not RegWriteStringValue(HKCU, Menu, 'MUIVerb', CustomMessage('OpenInPebrelWsl')) or + not RegWriteStringValue(HKCU, Menu, 'Icon', Executable + ',0') or + not RegWriteStringValue(HKCU, Menu, 'SubCommands', '') or + not RegWriteStringValue(HKCU, Menu, 'PebrelOwner', Executable) then + RaiseException('Unable to register the WSL context submenu.'); + for Index := 0 to GetArrayLength(Distros) - 1 do begin + Distro := Distros[Index]; + { 键名用序号而不是发行版名:注册表键名里带空格与非 ASCII 只会给自己找麻烦, + 何况我们靠前缀认领。 } + Verb := 'PebrelWsl' + IntToStr(Index); + { `--shell` 必须排在 `--working-directory` 之前:盘根(`D:\`)时后者的收尾 + 反斜杠会吃掉它的收尾引号,并把后面整段并进同一个参数(issue #36 的另一面), + 顺序写反会静默开出一个既没有 cwd、也没用上指定发行版的标签。 } + Command := '"' + Executable + '" --gpui --shell "wsl:' + Distro + '" --working-directory '; + Key := Menu + '\shell\' + Verb; + if not RegWriteStringValue(HKCU, Key, 'MUIVerb', Distro) or + not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or + not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"' + DirectoryArgument + '"') then + RaiseException('Unable to register the WSL context menu for ' + Distro + '.'); end; end; procedure RegisterWslContextMenus; var Distros: TArrayOfString; - Index: Integer; - Distro, Verb, LabelText, Executable, Command, Key: string; + Executable: string; begin - { 先清掉上一次写下的:发行版被移除后,残留的索引键会指向一个已经不存在的发行版。 } - RemoveOwnedWslContextMenus; Distros := WslDistroNames; - if GetArrayLength(Distros) = 0 then - Exit; Executable := ExpandConstant('{app}\pebrel.exe'); - for Index := 0 to GetArrayLength(Distros) - 1 do begin - Distro := Distros[Index]; - { 键名用序号而不是发行版名:注册表键名里带空格与非 ASCII 只会给自己找麻烦, - 何况我们靠前缀认领。 } - Verb := 'PebrelWsl' + IntToStr(Index); - LabelText := CustomMessage('OpenInPebrelWsl') + ' (' + Distro + ')'; - { `--shell` 必须排在 `--working-directory` 之前:盘根(`D:\`)时后者的收尾 - 反斜杠会吃掉它的收尾引号,并把后面整段并进同一个参数(issue #36 的另一面), - 顺序写反会静默开出一个既没有 cwd、也没用上指定发行版的标签。 } - Command := '"' + Executable + '" --gpui --shell "wsl:' + Distro + '" --working-directory '; - Key := 'Software\Classes\Directory\shell\' + Verb; - if not RegWriteStringValue(HKCU, Key, 'MUIVerb', LabelText) or - not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or - not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"%1"') then - RaiseException('Unable to register the WSL context menu for ' + Distro + '.'); - Key := 'Software\Classes\Directory\Background\shell\' + Verb; - if not RegWriteStringValue(HKCU, Key, 'MUIVerb', LabelText) or - not RegWriteStringValue(HKCU, Key, 'Icon', Executable + ',0') or - not RegWriteStringValue(HKCU, Key + '\command', '', Command + '"%V"') then - RaiseException('Unable to register the WSL background context menu for ' + Distro + '.'); - end; + RegisterWslContextMenuAt('Software\Classes\Directory\shell', Executable, '%1', Distros); + RegisterWslContextMenuAt('Software\Classes\Directory\Background\shell', Executable, '%V', Distros); end; procedure MigrateLegacyIntegrations; diff --git a/scripts/installer.iss b/scripts/installer.iss index 517365a..bd5f026 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -77,7 +77,7 @@ english.AutoStart=Start Pebrel when I sign in to Windows english.InstallFont=Install Maple Mono font for the current user english.AddToPath=Add Pebrel to the user PATH english.OpenInPebrel=Open in Pebrel -english.OpenInPebrelWsl=Open in Pebrel +english.OpenInPebrelWsl=Open in Pebrel (WSL) english.LaunchProgram=Launch Pebrel english.UninstallProgram=Uninstall Pebrel english.CloseLegacyProgram=Close the application at %1, then retry the installation. @@ -89,7 +89,7 @@ chinesesimplified.AutoStart=登录 Windows 后启动 Pebrel chinesesimplified.InstallFont=为当前用户安装 Maple Mono 字体 chinesesimplified.AddToPath=将 Pebrel 添加到当前用户 PATH chinesesimplified.OpenInPebrel=在 Pebrel 中打开 -chinesesimplified.OpenInPebrelWsl=在 Pebrel 中打开 +chinesesimplified.OpenInPebrelWsl=在 Pebrel 中打开(WSL) chinesesimplified.LaunchProgram=启动 Pebrel chinesesimplified.UninstallProgram=卸载 Pebrel chinesesimplified.CloseLegacyProgram=请关闭 %1 中运行的程序,然后重试安装。 diff --git a/scripts/tests/installer-migration-fixture.iss b/scripts/tests/installer-migration-fixture.iss index 1adb965..69a2aaa 100644 --- a/scripts/tests/installer-migration-fixture.iss +++ b/scripts/tests/installer-migration-fixture.iss @@ -1,4 +1,4 @@ -#ifndef FixtureRoot +#ifndef FixtureRoot #error FixtureRoot must point at an isolated directory under the workspace tmp directory. #endif #define MigrationFixture @@ -15,6 +15,9 @@ OutputBaseFilename=installer-migration-fixture Compression=none SetupLogging=no +[CustomMessages] +OpenInPebrelWsl=Open in Pebrel (WSL) + [Code] #include "..\installer-migration.iss" @@ -180,6 +183,69 @@ begin Check(FileExists(NewLink), 'shortcut pointing at another program is preserved'); end; +procedure CheckContextMenus(Root: string); +var + RegistryRoot, ShellRoot, Executable, Value, Menu, ForeignCommand: string; + Distros, Names: TArrayOfString; + Index: Integer; + Failed: Boolean; +begin + RegistryRoot := 'Software\PebrelTestFixtures\' + ExtractFileName(Root); + Executable := Root + '\Program Files\pebrel.exe'; + ForeignCommand := '"D:\other\pebrel.exe" --gpui --shell "wsl:Other" --working-directory "%1"'; + try + for Index := 0 to 1 do begin + ShellRoot := RegistryRoot + '\root' + IntToStr(Index); + Menu := ShellRoot + '\PebrelWslMenu'; + Check(RegWriteStringValue(HKCU, ShellRoot + '\Pebrel\command', '', 'ordinary-open'), 'seed ordinary entry'); + Check(RegWriteStringValue(HKCU, ShellRoot + '\PebrelWslForeign\command', '', ForeignCommand), 'seed foreign entry'); + Check(RegWriteStringValue(HKCU, ShellRoot + '\PebrelWsl0\command', '', + '"' + Executable + '" --gpui --shell "wsl:Old" --working-directory "%1"'), 'seed old flat entry'); + SetArrayLength(Distros, 3); + Distros[0] := 'Debian'; + Distros[1] := 'Ubuntu Test'; + Distros[2] := '开发环境'; + if Index = 0 then Value := '%1' else Value := '%V'; + RegisterWslContextMenuAt(ShellRoot, Executable, Value, Distros); + Check(not RegKeyExists(HKCU, ShellRoot + '\PebrelWsl0'), 'migrate old flat entry'); + Check(RegGetSubkeyNames(HKCU, ShellRoot, Names) and (GetArrayLength(Names) = 3), 'only normal, cascade and foreign roots remain'); + Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 3), 'three distros live inside cascade'); + Check(RegQueryStringValue(HKCU, Menu, 'SubCommands', Value) and (Value = ''), 'enable static cascade'); + Check(RegQueryStringValue(HKCU, Menu + '\shell\PebrelWsl1\command', '', Value), 'child command exists'); + if Index = 0 then + Check(Value = '"' + Executable + '" --gpui --shell "wsl:Ubuntu Test" --working-directory "%1"', 'selected directory argv') + else + Check(Value = '"' + Executable + '" --gpui --shell "wsl:Ubuntu Test" --working-directory "%V"', 'background directory argv'); + Check(RegQueryStringValue(HKCU, Menu + '\shell\PebrelWsl2', 'MUIVerb', Value) and (Value = '开发环境'), 'Unicode label retained'); + RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros); + Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 3), 'repeat registration has no duplicates'); + SetArrayLength(Distros, 1); + RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros); + Check(RegGetSubkeyNames(HKCU, Menu + '\shell', Names) and (GetArrayLength(Names) = 1), 'removed distros leave no stale entries'); + Check(RegWriteStringValue(HKCU, Menu + '\shell\Custom\command', '', ForeignCommand), 'seed edited submenu'); + Failed := False; + try + RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros); + except + Failed := True; + end; + Check(Failed and RegKeyExists(HKCU, Menu + '\shell\Custom'), 'edited submenu is not overwritten'); + RemoveOwnedWslContextMenusAt(ShellRoot, Executable); + Check(RegKeyExists(HKCU, Menu + '\shell\Custom'), 'uninstall preserves edited subtree'); + Check(RegDeleteKeyIncludingSubkeys(HKCU, Menu + '\shell\Custom'), 'remove fixture edit'); + RemoveOwnedWslContextMenusAt(ShellRoot, 'D:\other\install.exe'); + Check(RegKeyExists(HKCU, Menu), 'other installation cannot remove this menu'); + SetArrayLength(Distros, 0); + RegisterWslContextMenuAt(ShellRoot, Executable, '%1', Distros); + Check(not RegKeyExists(HKCU, Menu), 'zero distros removes empty cascade'); + Check(RegQueryStringValue(HKCU, ShellRoot + '\PebrelWslForeign\command', '', Value) and (Value = ForeignCommand), 'foreign entry preserved'); + Check(RegQueryStringValue(HKCU, ShellRoot + '\Pebrel\command', '', Value) and (Value = 'ordinary-open'), 'ordinary entry preserved'); + end; + finally + RegDeleteKeyIncludingSubkeys(HKCU, RegistryRoot); + end; +end; + function InitializeSetup: Boolean; var Root, Report: string; @@ -190,6 +256,7 @@ begin CheckPayloadMigration(Root); CheckFailures(Root); CheckShortcuts(Root); + CheckContextMenus(Root); Report := 'PASS: ' + IntToStr(Checks) + ' migration checks'; except Report := 'FAIL: ' + GetExceptionMessage; From 4d6f049fa80616291640b821c9411c0b41afd6c9 Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 13:52:46 +0800 Subject: [PATCH 06/12] fix(wsl): forward truecolor capability into guest shells --- .../2026-09-23-local-color-capabilities.md | 8 ++++--- nebula_app/src/agent_env.rs | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/architecture/notes/nebula_app/platform/2026-09-23-local-color-capabilities.md b/architecture/notes/nebula_app/platform/2026-09-23-local-color-capabilities.md index 659ca41..0e31409 100644 --- a/architecture/notes/nebula_app/platform/2026-09-23-local-color-capabilities.md +++ b/architecture/notes/nebula_app/platform/2026-09-23-local-color-capabilities.md @@ -28,8 +28,8 @@ model choice and Codex version still control whether its animation runs. After refreshing a local Windows environment, fill missing `TERM` with `xterm-256color` and missing `COLORTERM` with `truecolor`. Preserve explicit -case-insensitive overrides and `NO_COLOR`/`FORCE_COLOR`. Keep the actual Pebrel -terminal identity. Existing OSC color-query and rendering paths remain authoritative. +case-insensitive overrides and `NO_COLOR`/`FORCE_COLOR`. Forward `COLORTERM` through the existing WSL environment allowlist, preserving +explicit forwarding flags. Keep the actual Pebrel terminal identity. Existing OSC color-query and rendering paths remain authoritative. ## Rejected alternatives @@ -50,7 +50,9 @@ Hook lifetime fix is unrelated to this environment declaration. Eight platform-environment tests pass, including an actual child launched with an explicitly complete environment. VT replay preserves message surfaces and Braille stars for all fifteen built-in themes, plus all 225 theme-switch pairs. -These are capability/rendering tests, not a full live Codex animation acceptance. +A native Debian control returned no `COLORTERM` without forwarding and +`truecolor` with it. These are capability/rendering tests, not a full live Codex +animation acceptance. ## Supersedes diff --git a/nebula_app/src/agent_env.rs b/nebula_app/src/agent_env.rs index 70768d7..5714be4 100644 --- a/nebula_app/src/agent_env.rs +++ b/nebula_app/src/agent_env.rs @@ -170,6 +170,9 @@ const WSLENV_ENTRIES: &[&str] = &[ crate::ai_hook::LEGACY_PANE_ENV, TERM_PROGRAM_ENV, TERM_PROGRAM_VERSION_ENV, + // WSL otherwise falls back to a 256-color TERM despite the host supporting + // truecolor. Keep the capability declaration; do not spoof another terminal. + "COLORTERM", "PEBREL_CLI/p", "PEBREL_BIN_DIR/p", PROCESS_ENV, @@ -311,6 +314,27 @@ mod tests { assert!(names.contains(&BIN_DIR_ENV), "{BIN_DIR_ENV} missing: {names:?}"); assert!(names.contains(&PROCESS_ENV), "{PROCESS_ENV} missing: {names:?}"); assert!(names.contains(&crate::runtime_api::ENDPOINT_ENV)); + assert!(names.contains(&"COLORTERM")); + } + + #[cfg(windows)] + #[test] + fn wsl_receives_truecolor_without_replacing_an_explicit_forwarding_rule() { + let mut env = HashMap::from([ + ("COLORTERM".to_owned(), "truecolor".to_owned()), + ("WSLENV".to_owned(), "KEEP/p".to_owned()), + ]); + apply(&mut env, 8); + assert_eq!(env["COLORTERM"], "truecolor"); + assert!(env["WSLENV"].split(':').any(|entry| entry == "COLORTERM")); + assert!(env["WSLENV"].split(':').any(|entry| entry == "KEEP/p")); + env.insert("WSLENV".into(), "KEEP/p:COLORTERM/u".into()); + env.insert("COLORTERM".into(), "24bit".into()); + apply(&mut env, 8); + assert_eq!(env["COLORTERM"], "24bit"); + let entries: Vec<_> = + env["WSLENV"].split(':').filter(|entry| variable_name(entry) == "COLORTERM").collect(); + assert_eq!(entries, ["COLORTERM/u"]); } #[cfg(windows)] From 6e781eaad049b888ae7a0ece30e885c6955c26e5 Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 14:09:04 +0800 Subject: [PATCH 07/12] test(terminal): use generic path fixtures and link feedback coverage --- nebula_app/src/display/hint.rs | 6 +++--- nebula_app/src/gpui_shell/terminal/link_underline.rs | 6 ++---- scripts/tests/user-feedback-regressions.md | 12 +++++++++++- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/nebula_app/src/display/hint.rs b/nebula_app/src/display/hint.rs index 719c4e5..0f7c294 100644 --- a/nebula_app/src/display/hint.rs +++ b/nebula_app/src/display/hint.rs @@ -777,12 +777,12 @@ mod tests { #[test] fn shell_prompt_symbols_are_not_part_of_clickable_home_paths() { for (text, expected) in [ - ("hello@userdk:/mnt/d/temp_build/nebula$ ", "/mnt/d/temp_build/nebula"), + ("alice@workstation:/mnt/d/project$ ", "/mnt/d/project"), ("root@host:/root# ", "/root"), ("user@host:/# ", "/"), - ("hello@userdk:~/.codex$ ", "~/.codex"), + ("alice@workstation:~/.codex$ ", "~/.codex"), ("root@host:~/work# ", "~/work"), - ("hello@userdk:~/工作文档$ echo test", "~/工作文档"), + ("alice@workstation:~/工作文档$ echo test", "~/工作文档"), ("~/cost$ ", "~/cost$"), ("~/repo# ", "~/repo#"), ("https://example.com/cost$ ", "https://example.com/cost$"), diff --git a/nebula_app/src/gpui_shell/terminal/link_underline.rs b/nebula_app/src/gpui_shell/terminal/link_underline.rs index 6cecc43..9d73d3c 100644 --- a/nebula_app/src/gpui_shell/terminal/link_underline.rs +++ b/nebula_app/src/gpui_shell/terminal/link_underline.rs @@ -91,12 +91,10 @@ mod tests { .sum(); std::hint::black_box((cells, segments)); } - use std::io::Write as _; - let _ = writeln!( - std::io::stderr().lock(), + crate::gpui_shell::try_write_stderr(format_args!( "{name}: 120x40 grid, {last_cells} linked columns, {:.1} us/pass (capture + dash geometry, no GPU)", started.elapsed().as_secs_f64() * 1_000_000.0 / 200.0 - ); + )); } } diff --git a/scripts/tests/user-feedback-regressions.md b/scripts/tests/user-feedback-regressions.md index fbf13ac..8f90512 100644 --- a/scripts/tests/user-feedback-regressions.md +++ b/scripts/tests/user-feedback-regressions.md @@ -33,10 +33,20 @@ Windows 右键菜单反馈。它是验收合同,不表示下列功能已经全 | LINK-CJK | OSC 8 中文/全角字符的虚线只画半字、错位 | 从真实 VT 输出构建网格,中文两格、空格、ASCII、组合字符均按列覆盖;逐格绘制与连续区间的物理像素集合相同 | `A开始 菜单Z`、纯中文、混排;DPI/分屏/窄窗/换行;不随字体回补或字形分段重置虚线 | 真实 OSC 8 网格与 16 种 DPI/格宽组合像素覆盖回归通过;原生 Nord / Paper 混排截图已核对 | | LINK-PATH | 英文路径下划线断开,带空格路径只匹配前半段 | 连续虚线相位;引号包围的盘符/UNC/`~/` 路径完整匹配;hover、点击使用同一边界;普通正文不误连 | `"C:\Program Files\Example Tools\probe.exe"`、中文与括号目录、相邻命令参数、路径换行 | 8 项 hint 测试与 5 项配置解析测试通过;原生带空格路径截图已核对 | | LINK-PROMPT | WSL `user@host:~/.codex$` 吞入 `$`;绝对路径提示符没有下划线 | `~/` 与 `/mnt/d/...` 提示符中的 `$`/`#` 不可点;真实文件名 `~/cost$`、URL 尾部 `$`、路径内部 `$` 保留;下划线与命中边界一致 | Bash/WSL 彩色提示符、root 提示符、输入命令后、换行;OSC 8 显式 URI 不被启发式改写 | 正反匹配/命中回归通过;绝对路径打开应使用所属 WSL 发行版,不得套用宿主目录或 SSH 目标 | -| CODEX-SPARKLE | 对照终端 中可见的 Codex 星点在 Pebrel 不显示 | 核对真彩色能力、OSC 10/11 回答、盲文点字与前/背景色;真实 PTY 录制/回放;关闭动画时不强制产生动画 | 相同 Codex/模型/设置,对照 WT;启动、输入、弹窗遮挡、焦点、主题切换;不能伪造 `WT_SESSION` 来冒充 WT | 完整 Windows 子环境漏真彩色声明已修复;8 项环境回归及 15 主题 VT 回放通过;真实产品星点字形/背景截图已核对;真实 Codex 动画仍需验收 | +| CODEX-SPARKLE | 对照终端 中可见的 Codex 星点在 Pebrel 不显示 | 核对真彩色能力、OSC 10/11 回答、盲文点字与前/背景色;真实 PTY 录制/回放;关闭动画时不强制产生动画 | 相同 Codex/模型/设置,对照 WT;启动、输入、弹窗遮挡、焦点、主题切换;不能伪造 `WT_SESSION` 来冒充 WT | 完整 Windows 子环境漏真彩色声明已修复;8 项环境回归、10 项身份/WSL 透传回归及 15 主题 VT 回放通过;真实产品星点字形/背景截图已核对;真实 Codex 动画仍需验收 | | CODEX-MESSAGE | **所有主题**的 Codex 用户消息与 AI 回复背景缺少区分 | 保留/正确映射应用给出的用户消息背景;未提供背景时不得凭屏幕文字猜测角色;消息文本对比度不退化 | 遍历主题注册表中的全部内置主题;输入框与已发送的单/多行、中英混排消息;深浅切换、滚动回看;用户消息应有可辨认浅色背景区 | 15 个内置主题 + 225 种主题切换的背景保留回归通过;真实产品 Nord / Paper 的背景和星点截图已核对;其余主题有自动回放覆盖,仍需原生视觉抽查 | | SHELL-MENU | 多个 WSL 发行版把资源管理器右键菜单铺满 | 保留一个普通“在 Pebrel 中打开”;其余发行版集中到一个级联菜单;零/一/多个发行版、重复安装、移除发行版、卸载与旧项迁移 | 文件夹对象/文件夹空白处;中文/空格/盘根路径;点击普通项与每个子项验证 Shell 和 cwd;保留第三方/其他安装拥有的键 | 已实现;真实 Inno 夹具累计 117 项通过,完整安装器编译通过;正式 Explorer 点击验收仍需进行 | +## 修复与评审入口 + +- Hook 命令及退出期限:[PR #260](https://github.com/Kuddev/pebrel/pull/260),关联 #254 / #255 及 #258 的前置占用故障。 +- 同版本修复判定:[PR #261](https://github.com/Kuddev/pebrel/pull/261),关联 #258。 +- 下划线与路径边界:[PR #262](https://github.com/Kuddev/pebrel/pull/262)。 +- Windows / WSL 真彩色能力:[PR #263](https://github.com/Kuddev/pebrel/pull/263),关联 #136。 +- WSL 级联菜单:[PR #264](https://github.com/Kuddev/pebrel/pull/264)。 + +本地组合回归与各 PR 的 CI、Code Owner 评审是不同证据;未合并、未发布不能写成已经交付。 + ## 当前测试入口 ```powershell From 66dd0dc203b3ed6d55d6464f2fd22e2542ff0036 Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 14:11:28 +0800 Subject: [PATCH 08/12] fix(installer): localize context submenu failure messages --- scripts/installer-migration.iss | 4 ++-- scripts/installer.iss | 4 ++++ scripts/tests/installer-migration-fixture.iss | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/installer-migration.iss b/scripts/installer-migration.iss index c281776..07f0288 100644 --- a/scripts/installer-migration.iss +++ b/scripts/installer-migration.iss @@ -384,7 +384,7 @@ begin Menu := Root + '\PebrelWslMenu'; { Never overwrite an unknown installation or an edited submenu. } if RegKeyExists(HKCU, Menu) and not IsOwnedWslMenu(Menu, Executable) then - RaiseException('An unowned or edited WSL context menu was preserved: ' + Menu); + RaiseException(FmtMessage(CustomMessage('WslMenuConflict'), [Menu])); RemoveOwnedWslContextMenusAt(Root, Executable); if GetArrayLength(Distros) = 0 then Exit; @@ -392,7 +392,7 @@ begin not RegWriteStringValue(HKCU, Menu, 'Icon', Executable + ',0') or not RegWriteStringValue(HKCU, Menu, 'SubCommands', '') or not RegWriteStringValue(HKCU, Menu, 'PebrelOwner', Executable) then - RaiseException('Unable to register the WSL context submenu.'); + RaiseException(CustomMessage('WslMenuRegistrationFailed')); for Index := 0 to GetArrayLength(Distros) - 1 do begin Distro := Distros[Index]; { 键名用序号而不是发行版名:注册表键名里带空格与非 ASCII 只会给自己找麻烦, diff --git a/scripts/installer.iss b/scripts/installer.iss index bd5f026..e9e5028 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -78,6 +78,8 @@ english.InstallFont=Install Maple Mono font for the current user english.AddToPath=Add Pebrel to the user PATH english.OpenInPebrel=Open in Pebrel english.OpenInPebrelWsl=Open in Pebrel (WSL) +english.WslMenuConflict=An existing WSL submenu belongs to another installation or was edited, so it was preserved: %1. +english.WslMenuRegistrationFailed=Unable to register the WSL context submenu. english.LaunchProgram=Launch Pebrel english.UninstallProgram=Uninstall Pebrel english.CloseLegacyProgram=Close the application at %1, then retry the installation. @@ -90,6 +92,8 @@ chinesesimplified.InstallFont=为当前用户安装 Maple Mono 字体 chinesesimplified.AddToPath=将 Pebrel 添加到当前用户 PATH chinesesimplified.OpenInPebrel=在 Pebrel 中打开 chinesesimplified.OpenInPebrelWsl=在 Pebrel 中打开(WSL) +chinesesimplified.WslMenuConflict=现有 WSL 子菜单属于其他安装或已被修改,因此已保留:%1。 +chinesesimplified.WslMenuRegistrationFailed=无法注册 WSL 右键子菜单。 chinesesimplified.LaunchProgram=启动 Pebrel chinesesimplified.UninstallProgram=卸载 Pebrel chinesesimplified.CloseLegacyProgram=请关闭 %1 中运行的程序,然后重试安装。 diff --git a/scripts/tests/installer-migration-fixture.iss b/scripts/tests/installer-migration-fixture.iss index 69a2aaa..8052f54 100644 --- a/scripts/tests/installer-migration-fixture.iss +++ b/scripts/tests/installer-migration-fixture.iss @@ -17,6 +17,8 @@ SetupLogging=no [CustomMessages] OpenInPebrelWsl=Open in Pebrel (WSL) +WslMenuConflict=Preserved an edited or foreign submenu: %1. +WslMenuRegistrationFailed=Unable to register the WSL context submenu. [Code] #include "..\installer-migration.iss" From 0fca5833047dd19e4168873e4fe44734c8c5e3d9 Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 14:34:43 +0800 Subject: [PATCH 09/12] fix(updater): await helper file locks before starting setup --- .../2026-09-23-same-version-repair.md | 7 ++++- nebula_app/src/update_download/handoff.ps1 | 25 +++++++++++++++++ scripts/tests/fixtures/update_process.cs | 8 ++++-- scripts/tests/test_update_handoff.ps1 | 28 ++++++++++++++++--- 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/architecture/notes/packaging/2026-09-23-same-version-repair.md b/architecture/notes/packaging/2026-09-23-same-version-repair.md index ba603f0..3d1de6c 100644 --- a/architecture/notes/packaging/2026-09-23-same-version-repair.md +++ b/architecture/notes/packaging/2026-09-23-same-version-repair.md @@ -23,6 +23,9 @@ Retain package SHA-256/size checks, commit authority, participant identity and exit waiting, installer exit-code checks, and the installed version probe. Remove the additional requirement that a same-version main binary change hash. Record the installed hash as evidence, not as a repair-success predicate. +Before starting setup, retry exclusive write access to installed Hook helper files +for five seconds. Short-lived forwarding can finish; persistent locks fail before +any installation file is replaced. The helper never kills unrelated processes. ## Rejected alternatives @@ -39,11 +42,13 @@ asset naming rule changes. ## Validation -The native handoff suite passes ten scenarios, including a byte-identical main +The native handoff suite passes twelve scenarios, including a byte-identical main binary with a repaired helper. The former same-version/no-change assertion was incorrect; its negative intent is retained as `upgrade-noop`, which returns installer success but leaves the wrong application version and must still fail. Cancellation, checksum/identity failures and unprepared processes remain covered. +A released helper-file lock permits installation; a held lock must prevent setup +from starting and recover the unchanged original application. ## Supersedes diff --git a/nebula_app/src/update_download/handoff.ps1 b/nebula_app/src/update_download/handoff.ps1 index ccbdc63..7432de5 100644 --- a/nebula_app/src/update_download/handoff.ps1 +++ b/nebula_app/src/update_download/handoff.ps1 @@ -67,6 +67,30 @@ function Check-UnpreparedProcesses { } } +function Wait-RuntimeHelperFiles { + # New helpers have a bounded lifetime, but can still be draining as the + # application exits. Check before setup copies any file. Old stuck helpers + # remain a visible failure; never terminate processes by their image name. + $deadline = [DateTime]::UtcNow.AddSeconds(5) + $helpers = @('runtime\pebrel-hook.exe', 'pebrel-hook.exe', + 'runtime\nebula-hook.exe', 'nebula-hook.exe') + while ($true) { + $busy = $null + foreach ($relative in $helpers) { + $path = Join-Path $installation $relative + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue } + try { + $probe = [System.IO.FileStream]::new($path, [System.IO.FileMode]::Open, + [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) + $probe.Dispose() + } catch { $busy = $_.Exception } + } + if (-not $busy) { return } + if ([DateTime]::UtcNow -ge $deadline) { throw $busy } + Start-Sleep -Milliseconds 100 + } +} + try { if ((Get-Item -LiteralPath $PlanPath).Length -gt 1048576) { throw 'Update plan exceeds limit' } $plan = Get-Content -LiteralPath $PlanPath -Raw -Encoding UTF8 | ConvertFrom-Json @@ -133,6 +157,7 @@ try { Check-UnpreparedProcesses # No Restart Manager process-name shutdown: every participant has already # saved and exited. DIR reuses this validated installation without a chooser. + Wait-RuntimeHelperFiles $setupLog = Join-Path $transaction 'installer.log' $arguments = '/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /NOCLOSEAPPLICATIONS /NORESTARTAPPLICATIONS' + ' /DIR="' + $installation + '" /LOG="' + $setupLog + '"' diff --git a/scripts/tests/fixtures/update_process.cs b/scripts/tests/fixtures/update_process.cs index 4bdc702..606469e 100644 --- a/scripts/tests/fixtures/update_process.cs +++ b/scripts/tests/fixtures/update_process.cs @@ -17,9 +17,11 @@ class UpdateProcess { if (!File.Exists(Path.Combine(directory, "parent-exited"))) return 3; if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_FAIL_INSTALL") == "1") return 17; if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_NOOP_INSTALL") == "1") return 0; - File.Copy(Path.Combine(directory, "candidate.exe"), Path.Combine(target, "pebrel.exe"), true); - Directory.CreateDirectory(Path.Combine(target, "runtime")); - File.WriteAllText(Path.Combine(target, "runtime", "pebrel-hook.exe"), "repaired helper fixture"); + try { + File.Copy(Path.Combine(directory, "candidate.exe"), Path.Combine(target, "pebrel.exe"), true); + Directory.CreateDirectory(Path.Combine(target, "runtime")); + File.WriteAllText(Path.Combine(target, "runtime", "pebrel-hook.exe"), "repaired helper fixture"); + } catch (IOException) { return 5; } return 0; #else if (args.Length > 0 && args[0] == "--version") { diff --git a/scripts/tests/test_update_handoff.ps1 b/scripts/tests/test_update_handoff.ps1 index 2e05324..e92a31d 100644 --- a/scripts/tests/test_update_handoff.ps1 +++ b/scripts/tests/test_update_handoff.ps1 @@ -36,7 +36,7 @@ function Write-Json([string]$Path, $Value) { } $results = @() -foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'repair-identical', 'upgrade-noop')) { +foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) { $directory = Join-Path $OutputRoot $scenario $installation = Join-Path $directory 'installed app' $transaction = Join-Path $directory 'transaction' @@ -54,7 +54,15 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta $null = $parent.Handle $runner = $null $other = $null + $helperLock = $null try { + if ($scenario -in @('helper-unlocked', 'helper-held')) { + $null = New-Item -ItemType Directory -Path (Join-Path $installation 'runtime') -Force + $helperPath = Join-Path $installation 'runtime\pebrel-hook.exe' + [System.IO.File]::WriteAllText($helperPath, 'old helper fixture') + $helperLock = [System.IO.FileStream]::new($helperPath, [System.IO.FileMode]::Open, + [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) + } $plan = @{ schema = 1; transaction = $scenario; executable = $executable; installation = $installation config_directory = $config; installer = $installer @@ -104,8 +112,14 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran while the old process was alive' [System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit') Assert ($parent.WaitForExit(5000)) 'Fixture parent did not exit' + if ($scenario -eq 'helper-unlocked') { + Start-Sleep -Milliseconds 350 + Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup started with the helper still locked' + $helperLock.Dispose() + $helperLock = $null + } Wait-File $resultPath - if ($scenario -in @('success', 'installer-failure', 'reinstall', 'repair-identical', 'upgrade-noop')) { + if ($scenario -in @('success', 'installer-failure', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) { Wait-File (Join-Path $directory 'new-launch') $launch = [System.IO.File]::ReadAllLines((Join-Path $directory 'new-launch')) Assert ($launch[0] -eq $executable) 'Relaunch selected another installation' @@ -117,25 +131,31 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta } Assert ($runner.WaitForExit(5000)) 'Helper did not finish' $result = Get-Content -LiteralPath $resultPath -Raw -Encoding UTF8 | ConvertFrom-Json - Assert ($result.success -eq ($scenario -in @('success', 'reinstall', 'repair-identical'))) 'Unexpected helper outcome' + Assert ($result.success -eq ($scenario -in @('success', 'reinstall', 'repair-identical', 'helper-unlocked'))) 'Unexpected helper outcome' if ($result.success) { $expected = (Get-FileHash -LiteralPath $payload -Algorithm SHA256).Hash Assert ($result.executable_sha256 -eq $expected) 'Installed binary differs from candidate' Assert (Test-Path -LiteralPath (Join-Path $installation 'runtime\pebrel-hook.exe')) 'Successful setup did not repair the helper' + Assert ([System.IO.File]::ReadAllText((Join-Path $installation 'runtime\pebrel-hook.exe')) -eq 'repaired helper fixture') 'Helper still contains the original payload' } if ($scenario -in @('other-process', 'late-process')) { Assert (-not $other.HasExited) 'Update stopped an unprepared process' Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran with an unprepared process' } - if ($scenario -in @('installer-failure', 'upgrade-noop')) { + if ($scenario -in @('installer-failure', 'upgrade-noop', 'helper-held')) { Assert $result.recovered_original 'Untouched old executable was not recovered' } + if ($scenario -eq 'helper-held') { + Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'A blocked helper allowed a partial installation' + Assert ($result.error -match 'pebrel-hook.exe') 'The blocked file is missing from the failure details' + } $probe = [System.IO.FileStream]::new($plan.guard_path, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) $probe.Dispose() $results += @{ scenario = $scenario; passed = $true } Write-Output "$scenario : passed" } finally { + if ($helperLock) { $helperLock.Dispose() } if ($runner -and -not $runner.HasExited) { $runner.Kill(); $runner.WaitForExit() } if (-not $parent.HasExited) { [System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit') From fc61d56a1cc8dfb75261d86f01c3d6c750d2fdee Mon Sep 17 00:00:00 2001 From: YinBuLiao Date: Wed, 23 Sep 2026 14:50:09 +0800 Subject: [PATCH 10/12] Localize tab context menu across supported languages --- nebula_app/i18n/de-DE.json | 11 +++ nebula_app/i18n/en-US.json | 12 ++- nebula_app/i18n/es-ES.json | 11 +++ nebula_app/i18n/fr-FR.json | 11 +++ nebula_app/i18n/it-IT.json | 11 +++ nebula_app/i18n/ja-JP.json | 11 +++ nebula_app/i18n/ko-KR.json | 11 +++ nebula_app/i18n/pt-BR.json | 11 +++ nebula_app/i18n/ru-RU.json | 11 +++ nebula_app/i18n/zh-CN.json | 12 ++- nebula_app/i18n/zh-TW.json | 12 ++- .../src/gpui_shell/workspace/tab_menu.rs | 79 ++++++++++--------- nebula_app/src/i18n/tests.rs | 33 ++++++++ 13 files changed, 194 insertions(+), 42 deletions(-) diff --git a/nebula_app/i18n/de-DE.json b/nebula_app/i18n/de-DE.json index af6269f..2c668ec 100644 --- a/nebula_app/i18n/de-DE.json +++ b/nebula_app/i18n/de-DE.json @@ -104,6 +104,7 @@ "new_window": "Neues Fenster", "close_tab": "Tab schließen", "duplicate_tab": "Tab duplizieren", + "copy_working_directory": "Arbeitsverzeichnis kopieren", "terminal": "Terminal", "connect": "Verbinden", "disconnect": "Trennen", @@ -173,5 +174,15 @@ "copied_lines": "{lines} Zeilen in die Zwischenablage kopiert", "paste_lines": "{lines} Zeilen einfügen?", "sent_to": "An {target} gesendet" + }, + "tab_menu": { + "fork_ai_session": "KI-Sitzung verzweigen", + "move_to_new_window": "In neues Fenster verschieben", + "export_as_workspace": "Als Arbeitsbereich exportieren…", + "split_left_right": "Links und rechts teilen", + "split_top_bottom": "Oben und unten teilen", + "move_left": "Nach links verschieben", + "move_right": "Nach rechts verschieben", + "tab_color": "Tab-Farbe" } } diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index c74b422..137445a 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -903,5 +903,15 @@ "files.path_placeholder": "Absolute or relative directory path", "files.path_choose": "Choose a directory", "files.path_open": "Open directory", - "files.path_failed": "Could not open this directory: {error}" + "files.path_failed": "Could not open this directory: {error}", + "tab_menu": { + "fork_ai_session": "Fork AI session", + "move_to_new_window": "Move to new window", + "export_as_workspace": "Export as workspace…", + "split_left_right": "Split left and right", + "split_top_bottom": "Split top and bottom", + "move_left": "Move left", + "move_right": "Move right", + "tab_color": "Tab color" + } } diff --git a/nebula_app/i18n/es-ES.json b/nebula_app/i18n/es-ES.json index 080b3c6..ddd85cf 100644 --- a/nebula_app/i18n/es-ES.json +++ b/nebula_app/i18n/es-ES.json @@ -104,6 +104,7 @@ "new_window": "Nueva ventana", "close_tab": "Cerrar pestaña", "duplicate_tab": "Duplicar pestaña", + "copy_working_directory": "Copiar directorio de trabajo", "terminal": "Terminal", "connect": "Conectar", "disconnect": "Desconectar", @@ -173,5 +174,15 @@ "copied_lines": "Se copiaron {lines} líneas al portapapeles", "paste_lines": "¿Pegar {lines} líneas?", "sent_to": "Enviado a {target}" + }, + "tab_menu": { + "fork_ai_session": "Bifurcar sesión de IA", + "move_to_new_window": "Mover a una ventana nueva", + "export_as_workspace": "Exportar como espacio de trabajo…", + "split_left_right": "Dividir a izquierda y derecha", + "split_top_bottom": "Dividir arriba y abajo", + "move_left": "Mover a la izquierda", + "move_right": "Mover a la derecha", + "tab_color": "Color de la pestaña" } } diff --git a/nebula_app/i18n/fr-FR.json b/nebula_app/i18n/fr-FR.json index 1c2afd0..c6f6e89 100644 --- a/nebula_app/i18n/fr-FR.json +++ b/nebula_app/i18n/fr-FR.json @@ -154,6 +154,7 @@ "new_window": "Nouvelle fenêtre", "close_tab": "Fermer l’onglet", "duplicate_tab": "Dupliquer l’onglet", + "copy_working_directory": "Copier le répertoire de travail", "terminal": "Terminal", "connect": "Connecter", "disconnect": "Déconnecter", @@ -244,5 +245,15 @@ "http_status": "Le service a renvoyé HTTP {status}", "start_failed": "Impossible de démarrer le test du fournisseur : {error}" } + }, + "tab_menu": { + "fork_ai_session": "Dupliquer la session IA", + "move_to_new_window": "Déplacer vers une nouvelle fenêtre", + "export_as_workspace": "Exporter en espace de travail…", + "split_left_right": "Diviser verticalement", + "split_top_bottom": "Diviser horizontalement", + "move_left": "Déplacer vers la gauche", + "move_right": "Déplacer vers la droite", + "tab_color": "Couleur de l’onglet" } } diff --git a/nebula_app/i18n/it-IT.json b/nebula_app/i18n/it-IT.json index 2c05661..1de3815 100644 --- a/nebula_app/i18n/it-IT.json +++ b/nebula_app/i18n/it-IT.json @@ -104,6 +104,7 @@ "new_window": "Nuova finestra", "close_tab": "Chiudi scheda", "duplicate_tab": "Duplica scheda", + "copy_working_directory": "Copia la directory di lavoro", "terminal": "Terminale", "connect": "Connetti", "disconnect": "Disconnetti", @@ -173,5 +174,15 @@ "copied_lines": "{lines} righe copiate negli appunti", "paste_lines": "Incollare {lines} righe?", "sent_to": "Inviato a {target}" + }, + "tab_menu": { + "fork_ai_session": "Dirama sessione IA", + "move_to_new_window": "Sposta in una nuova finestra", + "export_as_workspace": "Esporta come area di lavoro…", + "split_left_right": "Dividi a sinistra e a destra", + "split_top_bottom": "Dividi in alto e in basso", + "move_left": "Sposta a sinistra", + "move_right": "Sposta a destra", + "tab_color": "Colore della scheda" } } diff --git a/nebula_app/i18n/ja-JP.json b/nebula_app/i18n/ja-JP.json index 0a56ca2..1b83876 100644 --- a/nebula_app/i18n/ja-JP.json +++ b/nebula_app/i18n/ja-JP.json @@ -104,6 +104,7 @@ "new_window": "新しいウィンドウ", "close_tab": "タブを閉じる", "duplicate_tab": "タブを複製", + "copy_working_directory": "作業ディレクトリをコピー", "terminal": "ターミナル", "connect": "接続", "disconnect": "切断", @@ -173,5 +174,15 @@ "copied_lines": "{lines} 行をクリップボードにコピーしました", "paste_lines": "{lines} 行を貼り付けますか?", "sent_to": "{target} に送信しました" + }, + "tab_menu": { + "fork_ai_session": "AI セッションを分岐", + "move_to_new_window": "新しいウィンドウに移動", + "export_as_workspace": "ワークスペースとしてエクスポート…", + "split_left_right": "左右に分割", + "split_top_bottom": "上下に分割", + "move_left": "左に移動", + "move_right": "右に移動", + "tab_color": "タブの色" } } diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json index 467393f..19a9fd6 100644 --- a/nebula_app/i18n/ko-KR.json +++ b/nebula_app/i18n/ko-KR.json @@ -104,6 +104,7 @@ "new_window": "새 창", "close_tab": "탭 닫기", "duplicate_tab": "탭 복제", + "copy_working_directory": "작업 디렉터리 복사", "terminal": "터미널", "connect": "연결", "disconnect": "연결 해제", @@ -173,5 +174,15 @@ "copied_lines": "{lines}줄을 클립보드에 복사했습니다", "paste_lines": "{lines}줄을 붙여넣을까요?", "sent_to": "{target}(으)로 전송했습니다" + }, + "tab_menu": { + "fork_ai_session": "AI 세션 분기", + "move_to_new_window": "새 창으로 이동", + "export_as_workspace": "작업 공간으로 내보내기…", + "split_left_right": "좌우로 분할", + "split_top_bottom": "위아래로 분할", + "move_left": "왼쪽으로 이동", + "move_right": "오른쪽으로 이동", + "tab_color": "탭 색상" } } diff --git a/nebula_app/i18n/pt-BR.json b/nebula_app/i18n/pt-BR.json index d8a00b9..645a6e3 100644 --- a/nebula_app/i18n/pt-BR.json +++ b/nebula_app/i18n/pt-BR.json @@ -104,6 +104,7 @@ "new_window": "Nova janela", "close_tab": "Fechar aba", "duplicate_tab": "Duplicar aba", + "copy_working_directory": "Copiar diretório de trabalho", "terminal": "Terminal", "connect": "Conectar", "disconnect": "Desconectar", @@ -173,5 +174,15 @@ "copied_lines": "{lines} linhas copiadas para a área de transferência", "paste_lines": "Colar {lines} linhas?", "sent_to": "Enviado para {target}" + }, + "tab_menu": { + "fork_ai_session": "Bifurcar sessão de IA", + "move_to_new_window": "Mover para nova janela", + "export_as_workspace": "Exportar como espaço de trabalho…", + "split_left_right": "Dividir lado a lado", + "split_top_bottom": "Dividir em cima e embaixo", + "move_left": "Mover para a esquerda", + "move_right": "Mover para a direita", + "tab_color": "Cor da aba" } } diff --git a/nebula_app/i18n/ru-RU.json b/nebula_app/i18n/ru-RU.json index 14e5a1e..e039409 100644 --- a/nebula_app/i18n/ru-RU.json +++ b/nebula_app/i18n/ru-RU.json @@ -104,6 +104,7 @@ "new_window": "Новое окно", "close_tab": "Закрыть вкладку", "duplicate_tab": "Дублировать вкладку", + "copy_working_directory": "Копировать рабочий каталог", "terminal": "Терминал", "connect": "Подключиться", "disconnect": "Отключиться", @@ -173,5 +174,15 @@ "copied_lines": "Скопировано строк в буфер обмена: {lines}", "paste_lines": "Вставить строки ({lines})?", "sent_to": "Отправлено в {target}" + }, + "tab_menu": { + "fork_ai_session": "Создать ответвление сеанса ИИ", + "move_to_new_window": "Переместить в новое окно", + "export_as_workspace": "Экспортировать как рабочую область…", + "split_left_right": "Разделить слева и справа", + "split_top_bottom": "Разделить сверху и снизу", + "move_left": "Переместить влево", + "move_right": "Переместить вправо", + "tab_color": "Цвет вкладки" } } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 9bacfd5..607bb1b 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -903,5 +903,15 @@ "files.path_placeholder": "输入绝对路径或相对路径", "files.path_choose": "选择目录", "files.path_open": "打开目录", - "files.path_failed": "无法打开这个目录:{error}" + "files.path_failed": "无法打开这个目录:{error}", + "tab_menu": { + "fork_ai_session": "分叉 AI 会话", + "move_to_new_window": "移到新窗口", + "export_as_workspace": "导出为工作区…", + "split_left_right": "左右分屏", + "split_top_bottom": "上下分屏", + "move_left": "向左移动", + "move_right": "向右移动", + "tab_color": "标签颜色" + } } diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json index 9f3abf3..23e8cda 100644 --- a/nebula_app/i18n/zh-TW.json +++ b/nebula_app/i18n/zh-TW.json @@ -218,5 +218,15 @@ "session.choose_conversation": "選擇要繼續的對話…", "session.exit_incomplete_title": "仍有 AI 工作階段無法識別,繼續關閉?", "session.exit_incomplete_body": "關閉前會儲存視窗配置及已知的對話 ID,並停止正在執行的命令。尚未取得 ID 的對話,重新開啟後可能需要手動選擇。", - "session.exit_anyway": "儲存已知狀態並關閉" + "session.exit_anyway": "儲存已知狀態並關閉", + "tab_menu": { + "fork_ai_session": "分叉 AI 工作階段", + "move_to_new_window": "移到新視窗", + "export_as_workspace": "匯出為工作區…", + "split_left_right": "左右分割", + "split_top_bottom": "上下分割", + "move_left": "向左移動", + "move_right": "向右移動", + "tab_color": "分頁顏色" + } } diff --git a/nebula_app/src/gpui_shell/workspace/tab_menu.rs b/nebula_app/src/gpui_shell/workspace/tab_menu.rs index bf90b0d..345626b 100644 --- a/nebula_app/src/gpui_shell/workspace/tab_menu.rs +++ b/nebula_app/src/gpui_shell/workspace/tab_menu.rs @@ -27,8 +27,10 @@ use gpui::{ }; use gpui_component::menu::PopupMenuItem; +use crate::display::UiLanguage; use crate::display::color::Rgb; use crate::gpui_shell::prelude::*; +use crate::i18n::Message; use crate::session::LaunchSession; use nebula_split::SplitDirection; @@ -73,7 +75,7 @@ pub(super) fn copy_working_directory_item( let available = source.read(cx).working_directory().is_some(); let source = source.downgrade(); PopupMenuItem::new( - super::workspace_ui_language().text(crate::i18n::Message::CommonCopyWorkingDirectory), + crate::gpui_shell::config::ui_language(cx).text(Message::CommonCopyWorkingDirectory), ) .icon(IconName::Folder) .disabled(!available) @@ -101,6 +103,7 @@ impl NebulaWorkspace { } self.activate_tab(ix, window, cx); let terminal = self.tabs.get(ix).is_some_and(WorkspaceTab::is_terminal); + let language = crate::gpui_shell::config::ui_language(cx); let ai_fork = tab_ai_fork_enabled(self, ix, cx); let color = self.meta(ix).color; let tab_count = self.tabs.len(); @@ -116,25 +119,18 @@ impl NebulaWorkspace { let menu = PopupMenu::build(window, cx, move |mut menu, _window, _cx| { if let Some(view) = choose_session { menu = menu.item( - PopupMenuItem::new( - super::workspace_ui_language() - .text(crate::i18n::Message::SessionChooseConversation), - ) - .on_click(move |_, _, cx| { - view.update(cx, |view, cx| view.choose_recovery_session(cx)) - }), + PopupMenuItem::new(language.text(Message::SessionChooseConversation)).on_click( + move |_, _, cx| { + view.update(cx, |view, cx| view.choose_recovery_session(cx)) + }, + ), ); } if let Some(view) = retry { menu = menu - .item( - PopupMenuItem::new( - super::workspace_ui_language().text(crate::i18n::Message::SessionRetry), - ) - .on_click(move |_, _, cx| { - view.update(cx, |view, cx| view.retry_recovery(cx)) - }), - ) + .item(PopupMenuItem::new(language.text(Message::SessionRetry)).on_click( + move |_, _, cx| view.update(cx, |view, cx| view.retry_recovery(cx)), + )) .separator(); } if let Some(copy_cwd) = copy_cwd { @@ -148,6 +144,7 @@ impl NebulaWorkspace { ai_fork, color, tab_count, + language, ) }); menu.focus_handle(cx).focus(window, cx); @@ -187,19 +184,22 @@ impl NebulaWorkspace { ai_fork: bool, color: Option, tab_count: usize, + language: UiLanguage, ) -> PopupMenu { if ai_fork { let target = workspace.clone(); menu = menu - .item(PopupMenuItem::new("分叉 AI 会话").icon(IconName::Bot).on_click( - move |_, window, cx| { - if let Some(workspace) = target.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.fork_ai_session(ix, window, cx); - }); - } - }, - )) + .item( + PopupMenuItem::new(language.text(Message::TabMenuForkAiSession)) + .icon(IconName::Bot) + .on_click(move |_, window, cx| { + if let Some(workspace) = target.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.fork_ai_session(ix, window, cx); + }); + } + }), + ) .separator(); } if terminal { @@ -209,7 +209,7 @@ impl NebulaWorkspace { let split_right = workspace.clone(); let split_down = workspace.clone(); menu = menu - .item(PopupMenuItem::new("复制标签页").icon(IconName::Copy).on_click( + .item(PopupMenuItem::new(language.text(Message::CommonDuplicateTab)).icon(IconName::Copy).on_click( move |_, window, cx| { if let Some(workspace) = duplicate.upgrade() { workspace.update(cx, |workspace, cx| { @@ -219,7 +219,7 @@ impl NebulaWorkspace { }, )) .item( - PopupMenuItem::new("移到新窗口") + PopupMenuItem::new(language.text(Message::TabMenuMoveToNewWindow)) .icon(IconName::ExternalLink) .on_click(move |_, _, cx| { if let Some(workspace) = move_to_window.upgrade() { @@ -229,7 +229,7 @@ impl NebulaWorkspace { } }), ) - .item(PopupMenuItem::new("导出为工作区…").icon(IconName::Inbox).on_click( + .item(PopupMenuItem::new(language.text(Message::TabMenuExportAsWorkspace)).icon(IconName::Inbox).on_click( move |_, window, cx| { if let Some(workspace) = export.upgrade() { workspace.update(cx, |workspace, cx| { @@ -242,7 +242,7 @@ impl NebulaWorkspace { // `action` 只用来渲染键帽:handler 存在时组件不会 dispatch // 它(见 PopupMenu::confirm),所以命令仍然作用在 `ix` 上。 .item( - PopupMenuItem::new("左右分屏") + PopupMenuItem::new(language.text(Message::TabMenuSplitLeftRight)) .icon(IconName::PanelRight) .action(Box::new(SplitRight)) .on_click(move |_, window, cx| { @@ -259,7 +259,7 @@ impl NebulaWorkspace { }), ) .item( - PopupMenuItem::new("上下分屏") + PopupMenuItem::new(language.text(Message::TabMenuSplitTopBottom)) .icon(IconName::PanelBottom) .action(Box::new(SplitDown)) .on_click(move |_, window, cx| { @@ -286,7 +286,7 @@ impl NebulaWorkspace { // 分屏两项)。首/末位灰掉而不是隐藏——菜单条目忽隐忽现比灰掉 // 更难认。 .item( - PopupMenuItem::new("向左移动") + PopupMenuItem::new(language.text(Message::TabMenuMoveLeft)) .icon(IconName::ArrowLeft) .action(Box::new(MoveTabLeft)) .disabled(ix == 0) @@ -300,7 +300,7 @@ impl NebulaWorkspace { }), ) .item( - PopupMenuItem::new("向右移动") + PopupMenuItem::new(language.text(Message::TabMenuMoveRight)) .icon(IconName::ArrowRight) .action(Box::new(MoveTabRight)) .disabled(ix + 1 >= tab_count) @@ -315,7 +315,7 @@ impl NebulaWorkspace { ) .separator() .item( - PopupMenuItem::new("重命名") + PopupMenuItem::new(language.text(Message::CommonRename)) .icon(IconName::ALargeSmall) .action(Box::new(RenameActiveTab)) .on_click(move |_, window, cx| { @@ -327,7 +327,7 @@ impl NebulaWorkspace { }), ) .item( - PopupMenuItem::new("关闭") + PopupMenuItem::new(language.text(Message::CommonClose)) .icon(IconName::Close) .action(Box::new(CloseActiveTerminal)) .on_click(move |_, window, cx| { @@ -338,7 +338,7 @@ impl NebulaWorkspace { } }), ); - Self::tab_color_items(menu, workspace, ix, color) + Self::tab_color_items(menu, workspace, ix, color, language) } /// 标签颜色行(旧壳菜单尾部的色板):首槽 `A` = 无色,其后是 7 枚品牌 @@ -348,9 +348,10 @@ impl NebulaWorkspace { workspace: gpui::WeakEntity, ix: usize, current: Option, + language: UiLanguage, ) -> PopupMenu { - menu.separator().item(PopupMenuItem::label("标签颜色")).item(PopupMenuItem::element( - move |_, cx| { + menu.separator().item(PopupMenuItem::label(language.text(Message::TabMenuTabColor))).item( + PopupMenuItem::element(move |_, cx| { let swatches = std::iter::once(None) .chain(crate::display::context_menu::TAB_COLORS.into_iter().map(Some)); let mut row = h_flex().gap_1().py_1(); @@ -395,8 +396,8 @@ impl NebulaWorkspace { ); } row - }, - )) + }), + ) } } diff --git a/nebula_app/src/i18n/tests.rs b/nebula_app/src/i18n/tests.rs index f5edbc7..1bd7880 100644 --- a/nebula_app/src/i18n/tests.rs +++ b/nebula_app/src/i18n/tests.rs @@ -33,6 +33,39 @@ fn typed_and_compatibility_lookups_agree() { } } +#[test] +fn tab_context_menu_has_translations_for_every_supported_language() { + let items = [ + Message::CommonCopyWorkingDirectory, + Message::CommonDuplicateTab, + Message::TabMenuForkAiSession, + Message::TabMenuMoveToNewWindow, + Message::TabMenuExportAsWorkspace, + Message::TabMenuSplitLeftRight, + Message::TabMenuSplitTopBottom, + Message::TabMenuMoveLeft, + Message::TabMenuMoveRight, + Message::CommonRename, + Message::CommonClose, + Message::TabMenuTabColor, + ]; + for language in UiLanguage::ALL { + for item in items { + assert!(!language.text(item).is_empty(), "{}: {item:?}", language.code()); + if *language != UiLanguage::EnUs { + assert_ne!( + language.text(item), + UiLanguage::EnUs.text(item), + "{}: {item:?}", + language.code() + ); + } + } + } + assert_eq!(UiLanguage::KoKr.text(Message::CommonCopyWorkingDirectory), "작업 디렉터리 복사"); + assert_eq!(UiLanguage::KoKr.text(Message::TabMenuTabColor), "탭 색상"); +} + #[test] fn inline_migration_preserves_bilingual_text_and_english_fallback() { assert_eq!(UiLanguage::ZhCn.pick("网络", "Network"), "网络"); From dcdc4026b11ede16f45224dfb9d0c64c70e126fc Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 15:12:32 +0800 Subject: [PATCH 11/12] test: track update locks and reasoning animation transitions --- scripts/tests/user-feedback-regressions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/tests/user-feedback-regressions.md b/scripts/tests/user-feedback-regressions.md index 8f90512..b2928a5 100644 --- a/scripts/tests/user-feedback-regressions.md +++ b/scripts/tests/user-feedback-regressions.md @@ -26,14 +26,14 @@ Windows 右键菜单反馈。它是验收合同,不表示下列功能已经全 | HOOK-LIFETIME | #258 前置故障:关闭 Pebrel 后 Hook 仍占用安装文件 | 保持 stdin 打开、连接后不读取命名管道时,真实 Hook 子进程仍在期限内以 0 退出;Cursor 继续允许提交;用户原有 notifier 仍执行 | 已运行的旧版 Hook 不会自动获得新期限;分别验证新启动的 native Hook 与旧进程恢复步骤 | 7 项单元 + 5 项真实进程回归通过 | | HOOK-MIGRATE | 更新后旧 `hooks.json` / 归属标记仍含失效命令 | 旧受管命令迁移;重复安装幂等;保留 notify、第三方 Hook、显式禁用及用户编辑;卸载只移除自有条目 | 重启 Pebrel 后自动修复;用户修改过的冲突条目不能静默覆盖 | `ai_hook::` 120 通过、1 项既有忽略;后续改动需重跑相关项 | | UPDATE-403 | 手动检查更新返回 HTTP 403 | 公共 Release 无需用户 token;覆盖 403/429、正常 JSON、其他错误、代理、受限响应;下载仍须官方 URL、精确资产名和 SHA-256 | 同一网络直连/代理、启动检查/手动检查;离线不得假报最新或成功 | **等待日志,按用户要求暂停**;未验收、不结项 | -| UPDATE-EXIT5 | 安装器 `exit code 5`,当前/最新版本显示相同 | 失败不得标成功;保留原快照与安装日志;已退出进程、安装失败、部分替换、相同版本、校验失败分别验证 | 基于反馈者 `installer.log` 重现;核对原版本、目标版本、实际 EXE;不能把 Inno 退出码 5 当作 Win32 拒绝访问码 | 后续 #258 已提供修复安装成功日志;无限等待的 Hook 已补期限,真实锁定故障仍需与旧版本进程区分 | -| UPDATE-258 | [#258](https://github.com/Kuddev/pebrel/issues/258):同版本修复成功却因主程序哈希不变报错 | 同版本主程序字节不变、Hook 文件修复后应成功;安装器失败或目标版本仍错误必须失败 | 同版本重装、首次升级部分成功后的修复;日志、实际版本和结果提示一致;原会话快照保留 | 10 项原生 handoff 场景通过,包含 `repair-identical` 与 `upgrade-noop` 正反例 | +| UPDATE-EXIT5 | 安装器 `exit code 5`,当前/最新版本显示相同 | 失败不得标成功;保留原快照与安装日志;已退出进程、安装失败、部分替换、相同版本、校验失败分别验证 | 基于反馈者 `installer.log` 重现;核对原版本、目标版本、实际 EXE;不能把 Inno 退出码 5 当作 Win32 拒绝访问码 | 后续 #258 已提供修复安装成功日志;无限等待的 Hook 已补期限;安装前等待短暂占用释放,持续占用时在启动安装器前失败,避免部分替换;旧版本已有挂起进程仍需另行处理 | +| UPDATE-258 | [#258](https://github.com/Kuddev/pebrel/issues/258):同版本修复成功却因主程序哈希不变报错 | 同版本主程序字节不变、Hook 文件修复后应成功;安装器失败或目标版本仍错误必须失败 | 同版本重装、首次升级部分成功后的修复;日志、实际版本和结果提示一致;原会话快照保留 | 12 项原生 handoff 场景通过,包含 `repair-identical` 与 `upgrade-noop` 正反例 | | BLUR-236-A | [#236](https://github.com/Kuddev/pebrel/issues/236):Acrylic 在屏幕边缘透出背景内容 | 切换材质/透明度不重复创建资源;新窗口、关闭、失焦、最大化/还原、队列退出及运行库失败路径 | 0% 与非零不透明度;明显文字/色块背景;左右/上下屏幕边缘、最大化、任务栏恢复、混合 DPI;失焦不出现不透明灰底 | [PR #250](https://github.com/Kuddev/pebrel/pull/250) 尚未合并;其跨平台 CI 通过不等于本轮实机验收 | | BLUR-236-B | #236 的 Aero 与 Acrylic 兼容回退范围 | Windows 10、Win11 22H2+、缺失/不匹配运行库与失败挂接均可正常启动/切换/退出 | Aero 需单独验收;缺少受支持 Windows App Runtime 时旧边缘现象不能标为已解决 | #250 仅覆盖受支持 Acrylic;**Aero 和回退范围仍开放** | | LINK-CJK | OSC 8 中文/全角字符的虚线只画半字、错位 | 从真实 VT 输出构建网格,中文两格、空格、ASCII、组合字符均按列覆盖;逐格绘制与连续区间的物理像素集合相同 | `A开始 菜单Z`、纯中文、混排;DPI/分屏/窄窗/换行;不随字体回补或字形分段重置虚线 | 真实 OSC 8 网格与 16 种 DPI/格宽组合像素覆盖回归通过;原生 Nord / Paper 混排截图已核对 | | LINK-PATH | 英文路径下划线断开,带空格路径只匹配前半段 | 连续虚线相位;引号包围的盘符/UNC/`~/` 路径完整匹配;hover、点击使用同一边界;普通正文不误连 | `"C:\Program Files\Example Tools\probe.exe"`、中文与括号目录、相邻命令参数、路径换行 | 8 项 hint 测试与 5 项配置解析测试通过;原生带空格路径截图已核对 | | LINK-PROMPT | WSL `user@host:~/.codex$` 吞入 `$`;绝对路径提示符没有下划线 | `~/` 与 `/mnt/d/...` 提示符中的 `$`/`#` 不可点;真实文件名 `~/cost$`、URL 尾部 `$`、路径内部 `$` 保留;下划线与命中边界一致 | Bash/WSL 彩色提示符、root 提示符、输入命令后、换行;OSC 8 显式 URI 不被启发式改写 | 正反匹配/命中回归通过;绝对路径打开应使用所属 WSL 发行版,不得套用宿主目录或 SSH 目标 | -| CODEX-SPARKLE | 对照终端 中可见的 Codex 星点在 Pebrel 不显示 | 核对真彩色能力、OSC 10/11 回答、盲文点字与前/背景色;真实 PTY 录制/回放;关闭动画时不强制产生动画 | 相同 Codex/模型/设置,对照 WT;启动、输入、弹窗遮挡、焦点、主题切换;不能伪造 `WT_SESSION` 来冒充 WT | 完整 Windows 子环境漏真彩色声明已修复;8 项环境回归、10 项身份/WSL 透传回归及 15 主题 VT 回放通过;真实产品星点字形/背景截图已核对;真实 Codex 动画仍需验收 | +| CODEX-SPARKLE | 对照终端 中可见的 Codex 星点在 Pebrel 不显示 | 核对真彩色能力、OSC 10/11 回答、盲文点字与前/背景色;真实 PTY 录制/回放;关闭动画时不强制产生动画 | 相同 Codex 可执行文件/模型/设置,对照 WT;分别覆盖 low/high/xhigh/max/ultra、Max→xhigh 回退、持续星点与一次性等级动画;以 CLI 实际输出为准,不能由终端自行按等级添加/过滤星点;不能伪造 `WT_SESSION` 来冒充 WT | 完整 Windows 子环境漏真彩色声明已修复;8 项环境回归、10 项身份/WSL 透传回归及 15 主题 VT 回放通过;真实产品星点字形/背景截图已核对;已观察到 Codex 0.154.0 在 xhigh 持续输出变化的星点;其源码持续星点与 Max/Ultra 一次性动画分别控制;与对照终端的等级切换差异仍待验收 | | CODEX-MESSAGE | **所有主题**的 Codex 用户消息与 AI 回复背景缺少区分 | 保留/正确映射应用给出的用户消息背景;未提供背景时不得凭屏幕文字猜测角色;消息文本对比度不退化 | 遍历主题注册表中的全部内置主题;输入框与已发送的单/多行、中英混排消息;深浅切换、滚动回看;用户消息应有可辨认浅色背景区 | 15 个内置主题 + 225 种主题切换的背景保留回归通过;真实产品 Nord / Paper 的背景和星点截图已核对;其余主题有自动回放覆盖,仍需原生视觉抽查 | | SHELL-MENU | 多个 WSL 发行版把资源管理器右键菜单铺满 | 保留一个普通“在 Pebrel 中打开”;其余发行版集中到一个级联菜单;零/一/多个发行版、重复安装、移除发行版、卸载与旧项迁移 | 文件夹对象/文件夹空白处;中文/空格/盘根路径;点击普通项与每个子项验证 Shell 和 cwd;保留第三方/其他安装拥有的键 | 已实现;真实 Inno 夹具累计 117 项通过,完整安装器编译通过;正式 Explorer 点击验收仍需进行 | From 7e9a0357fdfe2f3862bbaeedbb75a07672b9079b Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 15:16:55 +0800 Subject: [PATCH 12/12] test(terminal): handle pending reads after readiness notifications --- nebula_terminal/src/tty/windows/blocking.rs | 22 +++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/nebula_terminal/src/tty/windows/blocking.rs b/nebula_terminal/src/tty/windows/blocking.rs index 9f46cec..0b5bd9e 100644 --- a/nebula_terminal/src/tty/windows/blocking.rs +++ b/nebula_terminal/src/tty/windows/blocking.rs @@ -402,6 +402,22 @@ mod tests { let event = Event::readable(KEY); let mut reader = UnblockedReader::new(ScriptedSource { rx, pending: Vec::new() }, 4096); + // Readiness permits another poll; it does not guarantee bytes. piper + // can deliberately yield for fairness and wake us again, and a queued + // notification can outlive the data that originally triggered it. + // Retry only after a notification so a genuinely lost wake still fails. + let read_chunk = |reader: &mut UnblockedReader, buf: &mut [u8]| { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let read = reader.try_read(buf); + if read > 0 { + return read; + } + assert!(Instant::now() < deadline, "readiness never produced data"); + assert!(readable_arrived(&poller, KEY), "pending read was not woken"); + } + }; + // 首次 register 自带一次无条件投递;先消化掉,后面等到的事件就只可能 // 来自 try_read 的补投。 reader.register(&poller, event, PollMode::Level); @@ -414,7 +430,8 @@ mod tests { ); let mut buf = [0u8; 64]; - assert_eq!(reader.try_read(&mut buf), 5); + assert_eq!(read_chunk(&mut reader, &mut buf), 5); + assert_eq!(&buf[..5], b"hello"); assert!(reader.pipe.is_empty(), "这一读须把管道读空,否则测不到 TOCTOU 的那一半"); assert!( @@ -427,6 +444,7 @@ mod tests { assert_eq!(reader.try_read(&mut buf), 0); tx.send(b"world".to_vec()).unwrap(); assert!(readable_arrived(&poller, KEY), "重新注册的 waker 失效,后续输出会卡住"); - assert_eq!(reader.try_read(&mut buf), 5); + assert_eq!(read_chunk(&mut reader, &mut buf), 5); + assert_eq!(&buf[..5], b"world"); } }