diff --git a/justfile b/justfile index f736d8c1f..38a45052b 100644 --- a/justfile +++ b/justfile @@ -19,7 +19,7 @@ maintenance-test: # Local interactive Windows Terminal input qualification (never runs in normal CI). [windows] test-windows-input *args: - pwsh -NoProfile -File scripts/test_windows_input.ps1 -AllowInputInjection {{args}} + pwsh -NoProfile -File scripts/test_windows_input.ps1 -AllowInputInjection -ClearClipboard {{args}} # Run one nextest filter, e.g. `just test-one codex_stale_working` test-one filter: diff --git a/scripts/test_windows_input.ps1 b/scripts/test_windows_input.ps1 index 364ce414d..42d23bd77 100644 --- a/scripts/test_windows_input.ps1 +++ b/scripts/test_windows_input.ps1 @@ -17,6 +17,7 @@ param( [int[]] $Heights = @(24, 50), [string] $OutputDirectory, [switch] $AllowInputInjection, + [switch] $ClearClipboard, [switch] $Manual, [switch] $MatrixOnly ) @@ -47,6 +48,19 @@ if (@($selectedCases | Where-Object id -eq 'mouse-focus-refresh').Count) { if ($controllerWindow -eq [IntPtr]::Zero) { throw 'Controller has no foreground window for focus-cycle qualification' } [HerdrInputGauntlet.Desktop]::AssertNotElevated([HerdrInputGauntlet.Desktop]::Pid($controllerWindow)) } +$needsClipboard = $false +foreach ($case in $selectedCases) { + if ($case.kind -in @('paste', 'mouse-interleave', 'clipboard-image', 'clipboard-mixed') -and + @($Modes | Where-Object { $case.expected.ContainsKey($_) }).Count) { $needsClipboard = $true; break } +} +$clipboardFormatsAtStart = if ($needsClipboard -and $ClearClipboard) { + [HerdrInputGauntlet.Desktop]::ClearClipboardForRun() +} else { [HerdrInputGauntlet.Desktop]::CountClipboardFormats() } +if ($needsClipboard -and -not $ClearClipboard -and $clipboardFormatsAtStart -ne 0) { + throw 'Clipboard is not empty; save its contents, then clear it or pass -ClearClipboard to discard it before running' +} +$clipboardFormatsCleared = if ($needsClipboard -and $ClearClipboard) { $clipboardFormatsAtStart } else { 0 } +Write-Host "Clipboard formats before build: $clipboardFormatsAtStart; cleared for test: $clipboardFormatsCleared" $sourceCommit = $null $sourceDirty = $null if (-not $ExePath) { @@ -78,11 +92,11 @@ Write-Host "Herdr under test: $exe" Write-Host "SHA-256: $exeHash" Write-Host "Modes: $($Modes -join ', '); widths: $($Widths -join ', '); heights: $($Heights -join ', ')" Write-Host "Channels: $($Channels -join ', '); paths: $($Paths -join ', '); cases: $(if (@($Cases).Count) { $Cases -join ', ' } else { 'all' })" -Write-Host "Clipboard formats at start: $([HerdrInputGauntlet.Desktop]::CountClipboardFormats()) (paste runs only when zero)" $document = @{ schema = 1; run = [IO.Path]::GetFileName($root); started = [DateTime]::UtcNow.ToString('O'); source_commit = $sourceCommit; source_dirty = $sourceDirty; exe = $exe; exe_sha256 = $exeHash; powershell = $PSVersionTable.PSVersion.ToString(); os = [Environment]::OSVersion.VersionString; profile = $Profile; controller_elevated = $false; observations = @(); hosts = @(); errors = @(); cleanup_errors = @(); + clipboard_clear_requested = [bool]$ClearClipboard; clipboard_formats_at_start = $clipboardFormatsAtStart; clipboard_formats_cleared = $clipboardFormatsCleared; widths = $Widths; heights = $Heights; modes = $Modes; channels = $Channels; paths = $Paths; cases = @($selectedCases | ForEach-Object id); note = 'Desktop input evidence; native qualification still required' } $rawReport = Join-Path $root 'observations.json' diff --git a/scripts/test_windows_input.py b/scripts/test_windows_input.py index a844ea62c..63329134d 100644 --- a/scripts/test_windows_input.py +++ b/scripts/test_windows_input.py @@ -200,6 +200,33 @@ class WindowsInputGauntletTests(unittest.TestCase): rows = {row[0]: row[1:] for row in qualification_matrix(result)} self.assertEqual(rows["Shift+Enter"][0], "PARTIAL") + def test_direct_legacy_limit_requires_unsupported_from_every_channel(self): + observations = [{"host": host, "case": "shift-enter", "path": "direct", "mode": "legacy", "status": status} + for host, status in (("stable", "unsupported"), ("preview", "not_run"))] + rows = {row[0]: row[1:] for row in qualification_matrix({"channels": ["stable", "preview"], "observations": observations})} + self.assertEqual(rows["Shift+Enter"][1], "PARTIAL") + + def test_qualification_matrix_keeps_proven_input_visible_when_geometry_is_unavailable(self): + observations = [{"host": host, "case": case, "path": "herdr", "mode": "legacy", "status": "pass", "width": 120} + for host in ("stable", "preview") for case in ("letter-a", "shift-letter")] + observations += [{"host": host, "case": "letter-a", "path": "herdr", "mode": "legacy", "status": "not_run", "width": 160} + for host in ("stable", "preview")] + observations += [{"host": host, "case": "shift-enter", "path": "direct", "mode": "legacy", "status": status} + for host in ("stable", "preview") for status in ("unsupported", "not_run")] + observations += [{"host": "stable", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "inconclusive"}, + {"host": "preview", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "pass"}, + {"host": "preview", "case": "shift-enter", "path": "direct", "mode": "kitty", "status": "not_run"}] + result = {"channels": ["stable", "preview"], "observations": observations} + rows = {row[0]: row[1:] for row in qualification_matrix(result)} + self.assertEqual(rows["Printable keys"][0], "PARTIAL") + self.assertEqual(rows["Shift+Enter"][1:], ("X - becomes Enter", "INCONCLUSIVE")) + self.assertEqual(rows["Multiline paste"][0], "NOT TESTED") + self.assertEqual(rows["Resize 120 -> 80"][0], "NOT TESTED") + observations[3]["status"] = "not_run" # Preview has no passing shift-letter observation. + self.assertEqual({row[0]: row[1] for row in qualification_matrix(result)}["Printable keys"], "NOT TESTED") + observations[3]["status"] = "fail" + self.assertEqual({row[0]: row[1] for row in qualification_matrix(result)}["Printable keys"], "FAIL") + def test_herdr_protocol_label_requires_every_run_to_prove_transport(self): result = {"hosts": [{"channel": "stable", "runs": [{"nonce": "one", "path": "herdr", "mode": "native"}, {"nonce": "two", "path": "herdr", "mode": "native"}]}], diff --git a/scripts/windows_input/Native.cs b/scripts/windows_input/Native.cs index 76cb8af78..9d6357534 100644 --- a/scripts/windows_input/Native.cs +++ b/scripts/windows_input/Native.cs @@ -108,7 +108,15 @@ namespace HerdrInputGauntlet { [DllImport("user32.dll", SetLastError=true)] static extern uint SendInput(uint count, Input[] events, int size); [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hwnd, out Rect rect); [DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int width, int height, uint flags); - [DllImport("user32.dll")] public static extern int CountClipboardFormats(); + [DllImport("user32.dll",EntryPoint="CountClipboardFormats",SetLastError=true)] static extern int NativeCountClipboardFormats(); + public static int CountClipboardFormats() { + int count=NativeCountClipboardFormats(); + if(count==0) { + int error=Marshal.GetLastWin32Error(); + if(error!=0) throw new Win32Exception(error,"Clipboard format count failed"); + } + return count; + } [DllImport("user32.dll")] public static extern uint GetClipboardSequenceNumber(); [DllImport("user32.dll")] static extern IntPtr GetClipboardOwner(); [DllImport("user32.dll")] static extern bool OpenClipboard(IntPtr owner); @@ -141,6 +149,14 @@ namespace HerdrInputGauntlet { if(result<0) Marshal.ThrowExceptionForHR(result); return checked((int)pid); } + public static int ClearClipboardForRun() { + if(!OpenClipboard(IntPtr.Zero)) throw new Exception("Clipboard busy; cannot start paste qualification"); + try { + int formats=CountClipboardFormats(); + if(formats!=0 && !EmptyClipboard()) throw new Exception("Clipboard clear failed"); + return formats; + } finally { CloseClipboard(); } + } public static uint SetEmptyClipboard(IntPtr owner,string text) { if(owner==IntPtr.Zero) throw new Exception("Clipboard owner is required"); diff --git a/scripts/windows_input/README.md b/scripts/windows_input/README.md index edebc8fcd..61b76bd22 100644 --- a/scripts/windows_input/README.md +++ b/scripts/windows_input/README.md @@ -33,11 +33,14 @@ or mouse buttons while starting a run. Normal controller cancellation runs clean bootstrap/probe leases also expire if the controller disappears. Lease expiry is not a replacement for a secure isolated desktop. -Clipboard tests require an **empty clipboard**. Clear it yourself only after -saving anything you need. The runner will not replace existing text, images, -rich formats, or files. It writes its synthetic text while holding the clipboard -lock and clears it afterward only if its sequence number is unchanged. A later -user clipboard update is left alone. No original clipboard contents are logged. +`just test-windows-input` clears the clipboard once before the build when the +selected cases use it. **Save anything you need first:** existing text, images, +files, and other formats are discarded and cannot be restored. Direct script +calls need `-ClearClipboard` for the same behavior; without it, a nonempty +clipboard stops the run before the build. The runner writes its synthetic data +while holding the clipboard lock and clears it afterward only if its sequence +number is unchanged. A later user clipboard update is left alone. No original +clipboard contents are logged. ## Run @@ -70,7 +73,7 @@ Or invoke the script directly: ```powershell pwsh -NoProfile -File scripts/test_windows_input.ps1 ` - -ExePath 'C:\test-app\herdr.exe' -AllowInputInjection ` + -ExePath 'C:\test-app\herdr.exe' -AllowInputInjection -ClearClipboard ` -StablePath 'C:\TerminalStable\WindowsTerminal.exe' ` -PreviewPath 'C:\TerminalPreview\WindowsTerminal.exe' ``` @@ -83,7 +86,7 @@ arrays normally: ```powershell .\scripts\test_windows_input.ps1 -ExePath 'C:\test-app\herdr.exe' ` - -AllowInputInjection -Modes legacy,kitty -Cases mouse-interleave,mode-transitions ` + -AllowInputInjection -ClearClipboard -Modes legacy,kitty -Cases mouse-interleave,mode-transitions ` -Widths 120 -Heights 30 ``` @@ -92,7 +95,7 @@ server with the remote clipboard bridge active. It needs no SSH host. A focused clipboard-image qualification is: ```powershell -.\scripts\test_windows_input.ps1 -AllowInputInjection ` +.\scripts\test_windows_input.ps1 -AllowInputInjection -ClearClipboard ` -Channels stable -Paths direct,herdr-remote -Modes legacy ` -Cases clipboard-image,clipboard-mixed -Widths 80 -Heights 24 ``` diff --git a/scripts/windows_input/report.py b/scripts/windows_input/report.py index cf37f30ed..74ce92cd7 100644 --- a/scripts/windows_input/report.py +++ b/scripts/windows_input/report.py @@ -419,18 +419,23 @@ def qualification_matrix(result): return "MANUAL" if {row.get("case") for row in matched} != case_ids: return "PARTIAL" if "pass" in statuses else "INCONCLUSIVE" if "inconclusive" in statuses else "NOT TESTED" - if required_channels and {(row.get("host"), row.get("case")) for row in matched} != { - (channel, case_id) for channel in required_channels for case_id in case_ids}: + expected_pairs = {(channel, case_id) for channel in required_channels for case_id in case_ids} + if required_channels and {(row.get("host"), row.get("case")) for row in matched} != expected_pairs: return "PARTIAL" if case_ids == {"mode-transitions"} and path == "direct" and statuses <= {"unsupported", "inconclusive"}: return "X - mOK ignored" - if path == "direct" and modes == {"legacy"} and statuses == {"unsupported"}: + if path == "direct" and modes == {"legacy"} and statuses - {"not_run"} == {"unsupported"}: + if {(row.get("host"), row.get("case")) for row in matched if row.get("status") == "unsupported"} != expected_pairs: + return "PARTIAL" return "X - becomes Enter" if case_ids == {"shift-enter"} else "X - loses modifier" - def case_passed(case_id): - statuses_for_case = {row.get("status") for row in matched if row.get("case") == case_id} + def case_passed(case_id, channel=None): + statuses_for_case = {row.get("status") for row in matched if row.get("case") == case_id + and (channel is None or row.get("host") == channel)} return "pass" in statuses_for_case or (width == 80 and path == "direct" and modes == {"legacy"} - and case_id == "shift-enter" and statuses_for_case == {"unsupported"}) + and case_id == "shift-enter" and statuses_for_case - {"not_run"} == {"unsupported"}) per_case_passed = all(case_passed(case_id) for case_id in case_ids) + if "not_run" in statuses and all(case_passed(case_id, channel) for channel in required_channels for case_id in case_ids): + return "PARTIAL" allowed = {"pass", "inconclusive"} if width == 80 and path == "direct" and modes == {"legacy"}: allowed.add("unsupported") diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs index b1a4d35fe..67753e8ef 100644 --- a/src/client/shell/aggregate_navigation.rs +++ b/src/client/shell/aggregate_navigation.rs @@ -288,7 +288,14 @@ pub(super) fn navigator_rows( Some(ClientNavigatorFilter::Done) => status == crate::api::schema::AgentStatus::Done, None => true, }; - let text = |value: &str| query.is_empty() || value.to_lowercase().contains(&query); + let words = query.split_whitespace().collect::>(); + let text = |value: &str| { + if words.is_empty() { + return true; + } + let value = value.to_lowercase(); + words.iter().all(|word| value.contains(word)) + }; let filtering = navigator.filter.is_some() || !query.is_empty(); let federated = endpoints.len() > 1; let depth_offset = u8::from(federated); diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index c8cc2d907..67e36f239 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -1080,6 +1080,42 @@ fn navigator_renders_every_terminal_in_workspace_sections() { assert!(visible.iter().all(|row| !row.contains("second"))); } +#[test] +fn navigator_search_matches_non_adjacent_words_without_losing_the_pane_target() { + let mut projected = snapshot(); + projected.panes[0].label = Some("alpha beta gamma".into()); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.open_navigator_overlay(); + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("navigator"); + }; + for (query, matches) in [ + ("alpha gamma", true), + (" ALP\tGAM ", true), + ("gamma alpha", true), + ("beta gamma", true), + ("alpha missing", false), + ("alphagamma", false), + ] { + navigator.query = query.into(); + navigator.selected = None; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let target = + super::super::aggregate_navigation::selected_navigator_target(&rows, navigator); + assert_eq!( + target, + matches.then(|| ClientNavigatorTarget::Pane { + endpoint_id: state.active_endpoint_id.clone(), + pane_id: "pane_1".into(), + }), + "query={query:?}" + ); + } +} + #[test] fn navigator_searches_ancestor_context_and_keeps_split_agents_individually_actionable() { let mut projected = snapshot();