feat(terminal): improve PowerShell prompt handling for virtual environments

- Updated the PowerShell prompt initialization to better manage virtual environment prompts, ensuring that the original prompt is preserved and restored correctly.
- Added a new test to validate that the virtual environment prompt survives idle repairs and deactivation, preventing recursive prompt issues.
- Enhanced the prompt wrapper logic to avoid cycles when dealing with shared delegates, improving overall stability.
This commit is contained in:
Kang committed 2026-10-01 14:55:48 +08:00
1 parent 0d8c0dc5eb
commit 3de2029125
2 files changed
+79 -7

No files matched your search

@@ -399,6 +399,8 @@ fn cmd_prompt_value(session_token: &str) -> String {
/// Install a wrapper without touching the user's profile. Metadata is emitted
/// before the original prompt, then the incoming `$?`/`$LASTEXITCODE` state is
/// restored so prompt themes keep both status and deliberate OSC-title behavior.
/// Each wrapper captures its delegate: venv may save an older wrapper as
/// `_OLD_VIRTUAL_PROMPT`, so changing a shared delegate would create a cycle.
fn pwsh_prompt_init_args(session_token: &str) -> Vec<String> {
let init = format!(
concat!(
@@ -412,14 +414,14 @@ fn pwsh_prompt_init_args(session_token: &str) -> Vec<String> {
"[Console]::Out.Write($__nt_esc + ']7;' + $__nt_uri + $__nt_st) }} catch {{ [Console]::Out.Write($__nt_esc + ']7;nyaterm-clear://{0}' + $__nt_st) }} }} else {{ [Console]::Out.Write($__nt_esc + ']7;nyaterm-clear://{0}' + $__nt_st) }} ",
"}}; ",
"$global:__nyaterm_local_ready_failed = {{ if (-not $global:__nyaterm_local_failure_reported) {{ $global:__nyaterm_local_failure_reported = $true; $__nt_esc = [char]27; [Console]::Out.Write($__nt_esc + ']7777;NyaTermReadyFailed:{0}' + $__nt_esc + '\\') }} }}; ",
"$global:__nyaterm_local_prompt_wrapper = {{ ",
"$global:__nyaterm_local_install_prompt = {{ param([scriptblock]$__nt_original_prompt); $global:__nyaterm_local_prev_prompt = $__nt_original_prompt; $global:__nyaterm_local_prompt_wrapper = {{ ",
"$__nt_success = $?; $__nt_had_last_exit = Test-Path variable:global:LASTEXITCODE; $__nt_last_exit = if ($__nt_had_last_exit) {{ $global:LASTEXITCODE }} else {{ $null }}; & $global:__nyaterm_local_report; ",
"if ($__nt_had_last_exit) {{ $global:LASTEXITCODE = $__nt_last_exit }} else {{ Remove-Variable LASTEXITCODE -Scope Global -ErrorAction Ignore }}; if ($__nt_success) {{ & {{}} }} else {{ Write-Error 'NyaTerm prompt status restore' -ErrorAction Ignore }}; ",
"$__nt_result = $null; try {{ if ($global:__nyaterm_local_prev_prompt) {{ if ($__nt_success) {{ & {{}} }} else {{ Write-Error 'NyaTerm prompt status restore' -ErrorAction Ignore }}; $__nt_result = & $global:__nyaterm_local_prev_prompt }} else {{ $__nt_result = 'PS ' + $executionContext.SessionState.Path.CurrentLocation.Path + ('>' * ($nestedPromptLevel + 1)) + ' ' }} }} finally {{ ",
"$__nt_current_prompt = $function:prompt; if (-not [object]::ReferenceEquals($__nt_current_prompt, $global:__nyaterm_local_prompt_wrapper)) {{ $global:__nyaterm_local_prev_prompt = $__nt_current_prompt; try {{ Set-Item Function:\\global:prompt -Value $global:__nyaterm_local_prompt_wrapper -Force -ErrorAction Stop }} catch {{ & $global:__nyaterm_local_ready_failed }} }} }}; $__nt_result ",
"}}; ",
"try {{ Set-Item Function:\\global:prompt -Value $global:__nyaterm_local_prompt_wrapper -Force -ErrorAction Stop }} catch {{ & $global:__nyaterm_local_ready_failed }}; ",
"try {{ if ($global:__nyaterm_local_prompt_guard) {{ $__nt_old_guard_id = $global:__nyaterm_local_prompt_guard.Id; foreach ($__nt_subscriber in @(Microsoft.PowerShell.Utility\\Get-EventSubscriber -SourceIdentifier PowerShell.OnIdle -ErrorAction Ignore)) {{ if ([object]::ReferenceEquals($__nt_subscriber.Action, $global:__nyaterm_local_prompt_guard)) {{ Microsoft.PowerShell.Utility\\Unregister-Event -SubscriptionId $__nt_subscriber.SubscriptionId -Force -ErrorAction Ignore; break }} }}; Microsoft.PowerShell.Core\\Remove-Job -Id $__nt_old_guard_id -Force -ErrorAction Ignore; $global:__nyaterm_local_prompt_guard = $null }}; $global:__nyaterm_local_prompt_guard = Microsoft.PowerShell.Utility\\Register-EngineEvent -SourceIdentifier PowerShell.OnIdle -Action {{ $__nt_current_prompt = $function:prompt; if (-not [object]::ReferenceEquals($__nt_current_prompt, $global:__nyaterm_local_prompt_wrapper)) {{ $global:__nyaterm_local_prev_prompt = $__nt_current_prompt; try {{ Set-Item Function:\\global:prompt -Value $global:__nyaterm_local_prompt_wrapper -Force -ErrorAction Stop; & $global:__nyaterm_local_report }} catch {{ & $global:__nyaterm_local_ready_failed }} }} }} }} catch {{ & $global:__nyaterm_local_ready_failed }}"
"$__nt_result = $null; try {{ if ($__nt_original_prompt) {{ if ($__nt_success) {{ & {{}} }} else {{ Write-Error 'NyaTerm prompt status restore' -ErrorAction Ignore }}; $__nt_result = & $__nt_original_prompt }} else {{ $__nt_result = 'PS ' + $executionContext.SessionState.Path.CurrentLocation.Path + ('>' * ($nestedPromptLevel + 1)) + ' ' }} }} finally {{ ",
"$__nt_current_prompt = $function:prompt; if (-not [object]::ReferenceEquals($__nt_current_prompt, $global:__nyaterm_local_prompt_wrapper)) {{ & $global:__nyaterm_local_install_prompt $__nt_current_prompt }} }}; $__nt_result ",
"}}.GetNewClosure(); ",
"try {{ Set-Item Function:\\global:prompt -Value $global:__nyaterm_local_prompt_wrapper -Force -ErrorAction Stop }} catch {{ & $global:__nyaterm_local_ready_failed }} }}; & $global:__nyaterm_local_install_prompt $global:__nyaterm_local_prev_prompt; ",
"try {{ if ($global:__nyaterm_local_prompt_guard) {{ $__nt_old_guard_id = $global:__nyaterm_local_prompt_guard.Id; foreach ($__nt_subscriber in @(Microsoft.PowerShell.Utility\\Get-EventSubscriber -SourceIdentifier PowerShell.OnIdle -ErrorAction Ignore)) {{ if ([object]::ReferenceEquals($__nt_subscriber.Action, $global:__nyaterm_local_prompt_guard)) {{ Microsoft.PowerShell.Utility\\Unregister-Event -SubscriptionId $__nt_subscriber.SubscriptionId -Force -ErrorAction Ignore; break }} }}; Microsoft.PowerShell.Core\\Remove-Job -Id $__nt_old_guard_id -Force -ErrorAction Ignore; $global:__nyaterm_local_prompt_guard = $null }}; $global:__nyaterm_local_prompt_guard = Microsoft.PowerShell.Utility\\Register-EngineEvent -SourceIdentifier PowerShell.OnIdle -Action {{ $__nt_current_prompt = $function:prompt; if (-not [object]::ReferenceEquals($__nt_current_prompt, $global:__nyaterm_local_prompt_wrapper)) {{ & $global:__nyaterm_local_install_prompt $__nt_current_prompt; & $global:__nyaterm_local_report }} }} }} catch {{ & $global:__nyaterm_local_ready_failed }}"
),
session_token
);
@@ -1593,7 +1593,7 @@ mod tests {
assert!(
init.find("]2;").unwrap()
< init
.find("& $global:__nyaterm_local_prev_prompt")
.find("& $__nt_original_prompt")
.unwrap()
);
assert!(init.contains("ReferenceEquals"));
@@ -1677,6 +1677,76 @@ mod tests {
);
}
#[cfg(target_os = "windows")]
#[test]
fn native_powershell_venv_prompt_survives_idle_repair_and_deactivation() {
use std::process::Command;
let init = build_local_startup_script("powershell.exe", &ready_marker(), true, true)
.pwsh_init_args
.expect("PowerShell init args")
.pop()
.expect("PowerShell init script");
// Match Activate.ps1's saved prompt and named invocation, and bound the
// prefix count so a regression fails instead of overflowing the stack.
let harness = format!(
r#"$ErrorActionPreference = 'Stop'
function global:prompt {{ 'USER> ' }}
{init}
$global:prefix_count = 0
function global:activate_test_venv {{
function global:_OLD_VIRTUAL_PROMPT {{ '' }}
Copy-Item Function:prompt Function:_OLD_VIRTUAL_PROMPT
function global:prompt {{
$global:prefix_count++
if ($global:prefix_count -gt 8) {{ throw 'recursive venv prompt' }}
'(venv) ' + (_OLD_VIRTUAL_PROMPT)
}}
}}
function repair_test_prompt {{
$null = New-Event -SourceIdentifier PowerShell.OnIdle
for ($i = 0; $i -lt 50; $i++) {{
if ([object]::ReferenceEquals($function:prompt, $global:__nyaterm_local_prompt_wrapper)) {{ return }}
Start-Sleep -Milliseconds 10
}}
throw 'idle prompt repair did not run'
}}
for ($cycle = 0; $cycle -lt 3; $cycle++) {{
activate_test_venv
repair_test_prompt
for ($i = 0; $i -lt 2; $i++) {{
$value = prompt
if ($value -cne '(venv) USER> ') {{ throw ('unexpected active prompt: ' + $value) }}
}}
Copy-Item Function:_OLD_VIRTUAL_PROMPT Function:global:prompt
Remove-Item Function:_OLD_VIRTUAL_PROMPT
repair_test_prompt
if ((prompt) -cne 'USER> ') {{ throw 'deactivation did not restore prompt' }}
}}
if ($global:prefix_count -ne 6) {{ throw ('unexpected prefix count: ' + $global:prefix_count) }}
Write-Output 'VENV_PROMPT_OK'
"#
);
let output = Command::new("powershell.exe")
.args(["-NoLogo", "-NoProfile", "-Command", &harness])
.output()
.expect("run Windows PowerShell venv prompt regression");
let mut combined = output.stdout;
combined.extend_from_slice(&output.stderr);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&combined)
);
assert!(
combined
.windows(b"VENV_PROMPT_OK".len())
.any(|window| window == b"VENV_PROMPT_OK"),
"{}",
String::from_utf8_lossy(&combined)
);
}
#[cfg(target_os = "windows")]
#[test]
fn native_powershell_prompt_wrapper_repairs_self_replacement() {