Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
- Known-agent integrations now leave pane ownership to confirmed process exit, so restarting Pi with the same saved session restores lifecycle state even with custom working UI. (#1792)
- OMP integration install, status, and uninstall now respect `PI_CONFIG_DIR` when `PI_CODING_AGENT_DIR` is not set, and installation refuses extension-directory collisions with Pi. (#1696)
- Physical Escape key records on native Windows now bypass raw VT report framing, so pane applications receive Escape immediately and reliably. (#1736)
- Native Windows key presses, grouped repeats, and releases now preserve their physical lifecycle and stay with the pane that received the initial press. (#2077)
- Windows now shows `system` notifications and completes MP3 notification sounds without leaving PowerShell players waiting for a timeout. (#1330)

## [0.7.5] - 2026-07-21
Expand Down
2 changes: 1 addition & 1 deletion docs/next/api/herdr-api.schema.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"protocol": 18,
"protocol": 19,
"schema_version": 1,
"schemas": {
"error_response": {
Expand Down
68 changes: 50 additions & 18 deletions scripts/windows_check.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,53 @@ function Invoke-CargoWithZigCacheRecovery {
Invoke-Checked cargo $Arguments
}

function Invoke-CargoTestFilter {
param(
[Parameter(Mandatory)]
[string]$Filter,
[switch]$Exact
)

$commonArguments = @(
"test",
"--locked",
"--target",
"x86_64-pc-windows-msvc",
"--bin",
"herdr",
$Filter
)
$harnessArguments = @("--list")
if ($Exact) {
$harnessArguments += "--exact"
}

$listArguments = $commonArguments + @("--") + $harnessArguments
$listOutput = @(& cargo @listArguments)
if ($LASTEXITCODE -ne 0) {
throw "could not enumerate tests for filter '$Filter': $($listOutput -join [Environment]::NewLine)"
}

$testNames = @(
foreach ($line in $listOutput) {
$match = [regex]::Match([string]$line, '^\s*(\S+): test\s*$')
if ($match.Success) {
$match.Groups[1].Value
}
}
)
if ($testNames.Count -eq 0) {
throw "test filter '$Filter' selected zero tests"
}

Write-Host "Running $($testNames.Count) test(s) for '$Filter'"
$runArguments = $commonArguments
if ($Exact) {
$runArguments += @("--", "--exact")
}
Invoke-Checked cargo $runArguments
}

Invoke-Checked rustup @("target", "add", "x86_64-pc-windows-msvc")
Invoke-Checked cargo @("fmt", "--check")
Invoke-CargoWithZigCacheRecovery @(
Expand All @@ -47,22 +94,7 @@ if ($Mode -eq "lint") {
return
}

Invoke-Checked cargo @(
"test",
"--locked",
"--target",
"x86_64-pc-windows-msvc",
"--bin",
"herdr",
"windows_"
)
Invoke-Checked cargo @(
"test",
"--locked",
"--target",
"x86_64-pc-windows-msvc",
"--bin",
"herdr",
"server::client_transport::tests"
)
Invoke-CargoTestFilter "windows_"
Invoke-CargoTestFilter "server::client_transport::tests"
Invoke-CargoTestFilter "app::tests::native_repeats_and_releases_follow_the_pressed_pane" -Exact
Invoke-Checked cargo @("build", "--locked", "--target", "x86_64-pc-windows-msvc")
169 changes: 141 additions & 28 deletions scripts/windows_conpty_enhanced_input_probe.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,34 @@ function Send-RawAndObserve {
}
}

function Send-NativeRecordAndObserve {
param(
[string] $PaneId,
[string] $Text,
[string] $ExpectedRecord
)
$needle = "PROBE_RECORD:$ExpectedRecord"
$before = ([regex]::Matches((Read-Pane -PaneId $PaneId), [regex]::Escape($needle))).Count
& $script:Exe pane send-text $PaneId $Text | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "pane send-text failed with exit code $LASTEXITCODE"
}
$deadline = (Get-Date).AddSeconds(10)
do {
$paneText = Read-Pane -PaneId $PaneId
$after = ([regex]::Matches($paneText, [regex]::Escape($needle))).Count
if ($after -gt $before) {
break
}
Start-Sleep -Milliseconds 200
} while ((Get-Date) -lt $deadline)
return [ordered]@{
expected_record = $needle
delivered = $after -gt $before
pane = $paneText
}
}

$script:Exe = (Resolve-Path $ExePath).Path
$workDir = Join-Path ([System.IO.Path]::GetTempPath()) "herdr-conpty-input-$([guid]::NewGuid().ToString('N'))"
$probeSource = Join-Path $workDir "probe.rs"
Expand Down Expand Up @@ -175,6 +203,12 @@ extern "system" {
bytes_read: *mut u32,
overlapped: *mut c_void,
) -> i32;
fn ReadConsoleInputW(
handle: Handle,
records: *mut InputRecord,
length: u32,
records_read: *mut u32,
) -> i32;
fn WriteFile(
handle: Handle,
buffer: *const c_void,
Expand All @@ -184,6 +218,31 @@ extern "system" {
) -> i32;
}

#[repr(C)]
#[derive(Clone, Copy)]
struct KeyEventRecord {
key_down: i32,
repeat_count: u16,
virtual_key_code: u16,
virtual_scan_code: u16,
unicode_char: u16,
control_key_state: u32,
}

#[repr(C)]
#[derive(Clone, Copy)]
union InputRecordEvent {
key: KeyEventRecord,
}

#[repr(C)]
struct InputRecord {
event_type: u16,
event: InputRecordEvent,
}

const KEY_EVENT: u16 = 0x0001;

fn write_all(handle: Handle, mut bytes: &[u8]) {
while !bytes.is_empty() {
let mut written = 0;
Expand Down Expand Up @@ -212,43 +271,88 @@ fn main() {
write_all(output, b"PROBE_ERROR_GET_MODE\r\n");
std::process::exit(1);
}
let raw_vt_mode = (console_mode | ENABLE_VIRTUAL_TERMINAL_INPUT)
& !(ENABLE_PROCESSED_INPUT | ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT);
if unsafe { SetConsoleMode(input, raw_vt_mode) } == 0 {
write_all(output, b"PROBE_ERROR_SET_MODE\r\n");
std::process::exit(2);
if mode != "native" {
let raw_vt_mode = (console_mode | ENABLE_VIRTUAL_TERMINAL_INPUT)
& !(ENABLE_PROCESSED_INPUT | ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT);
if unsafe { SetConsoleMode(input, raw_vt_mode) } == 0 {
write_all(output, b"PROBE_ERROR_SET_MODE\r\n");
std::process::exit(2);
}
}

if mode == "kitty" {
if mode == "native" {
write_all(output, b"PROBE_READY_NATIVE\r\n");
} else if mode == "kitty" {
write_all(output, b"\x1b[>7u\x1b[?u\x1b[cPROBE_READY_KITTY\r\n");
} else {
write_all(output, b"PROBE_READY_LEGACY\r\n");
}

let mut all = Vec::new();
let mut buffer = [0u8; 256];
loop {
let mut read = 0;
let ok = unsafe {
ReadFile(
input,
buffer.as_mut_ptr().cast(),
buffer.len() as u32,
&mut read,
std::ptr::null_mut(),
)
};
if ok == 0 || read == 0 {
break;
if mode == "native" {
let mut records: [InputRecord; 16] = std::array::from_fn(|_| InputRecord {
event_type: 0,
event: InputRecordEvent {
key: KeyEventRecord {
key_down: 0,
repeat_count: 0,
virtual_key_code: 0,
virtual_scan_code: 0,
unicode_char: 0,
control_key_state: 0,
},
},
});
loop {
let mut read = 0;
let ok = unsafe {
ReadConsoleInputW(input, records.as_mut_ptr(), records.len() as u32, &mut read)
};
if ok == 0 || read == 0 {
break;
}
for record in &records[..read as usize] {
if record.event_type != KEY_EVENT {
continue;
}
let key = unsafe { &record.event.key };
let line = format!(
"PROBE_RECORD:{};{};{};{};{};{}\r\n",
if key.key_down != 0 { 1 } else { 0 },
key.repeat_count,
key.virtual_key_code,
key.virtual_scan_code,
key.unicode_char,
key.control_key_state,
);
write_all(output, line.as_bytes());
}
}
all.extend_from_slice(&buffer[..read as usize]);
let mut line = String::from("PROBE_ALL:");
for byte in &all {
use std::fmt::Write as _;
let _ = write!(&mut line, "{byte:02x}");
} else {
let mut all = Vec::new();
let mut buffer = [0u8; 256];
loop {
let mut read = 0;
let ok = unsafe {
ReadFile(
input,
buffer.as_mut_ptr().cast(),
buffer.len() as u32,
&mut read,
std::ptr::null_mut(),
)
};
if ok == 0 || read == 0 {
break;
}
all.extend_from_slice(&buffer[..read as usize]);
let mut line = String::from("PROBE_ALL:");
for byte in &all {
use std::fmt::Write as _;
let _ = write!(&mut line, "{byte:02x}");
}
line.push_str("\r\n");
write_all(output, line.as_bytes());
}
line.push_str("\r\n");
write_all(output, line.as_bytes());
}
}
'@ | Set-Content -NoNewline -Encoding utf8 $probeSource
Expand Down Expand Up @@ -311,6 +415,13 @@ fn main() {
$report.raw_alt_v = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "v") -ExpectedHex "1b76"
$report.raw_ctrl_u = Send-RawAndObserve -PaneId $kittyPane -Text ([string][char]0x15) -ExpectedHex "15"

$nativePane = New-ProbePane -Mode "native"
# Win32 input mode records use CSI Vk;Scan;Unicode;Down;Control;Repeat _.
$nativeEscapeDown = [char]27 + "[27;1;27;1;0;3_"
$nativeEscapeRelease = [char]27 + "[27;1;27;0;0;1_"
$report.native_escape_down = Send-NativeRecordAndObserve -PaneId $nativePane -Text $nativeEscapeDown -ExpectedRecord "1;3;27;1;27;0"
$report.native_escape_release = Send-NativeRecordAndObserve -PaneId $nativePane -Text $nativeEscapeRelease -ExpectedRecord "0;1;27;1;27;0"

$consoleHosts = @(Get-Process -Name conhost, OpenConsole -ErrorAction SilentlyContinue |
Where-Object { $initialConsoleHostIds -notcontains $_.Id } |
ForEach-Object {
Expand Down Expand Up @@ -354,6 +465,8 @@ fn main() {
-or -not $report.raw_kitty_ctrl_delete.delivered `
-or -not $report.raw_alt_v.delivered `
-or -not $report.raw_ctrl_u.delivered `
-or -not $report.native_escape_down.delivered `
-or -not $report.native_escape_release.delivered `
-or ($appLocalHostRequired -and -not $report.app_local_console_host)
} finally {
if ($null -ne $server) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/panes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2678,7 +2678,7 @@ mod tests {
rx.try_recv().expect("forwarded release after pane move"),
bytes::Bytes::from_static(b"\x1b[106;1:3u")
);
assert!(app.pressed_terminal_keys.is_empty());
assert!(app.input_leases.is_empty());
}

#[test]
Expand Down
Loading
Loading