Merge branch 'master' into terminal-browser-kitty

This commit is contained in:
Can Celik
2026-09-24 16:31:20 +03:00
committed by GitHub
8 changed files with 126 additions and 18 deletions
+1 -1
View File
@@ -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:
+15 -1
View File
@@ -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'
+27
View File
@@ -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"}]}],
+17 -1
View File
@@ -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");
+11 -8
View File
@@ -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
```
+11 -6
View File
@@ -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")
+8 -1
View File
@@ -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::<Vec<_>>();
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);
+36
View File
@@ -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();