fix(input): preserve native key lifecycle across routing (#2142)

refs #2077

Co-authored-by: Jonathan Liebig <jonathan.liebig@gmail.com>
This commit is contained in:
Can Celik 2026-08-01 04:27:50 +03:00 committed by GitHub
parent 8d190eddda
commit b76adc155d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 2677 additions and 569 deletions

View File

@ -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

View File

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"protocol": 18,
"protocol": 19,
"schema_version": 1,
"schemas": {
"error_response": {

View File

@ -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 @(
@ -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")

View File

@ -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"
@ -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,
@ -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;
@ -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
@ -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 {
@ -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) {

View File

@ -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]

View File

@ -5,7 +5,9 @@ use crate::{
input::TerminalKey,
};
fn is_retained_selection_copy_key(key: TerminalKey) -> bool {
use super::{ConsumedInputLease, InputLeaseKey};
fn is_retained_selection_copy_key(key: &TerminalKey) -> bool {
matches!(key.code, KeyCode::Char('c' | 'C'))
&& matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER)
}
@ -31,7 +33,7 @@ impl App {
key: TerminalKey,
) -> bool {
if self.state.copy_on_select
|| !is_retained_selection_copy_key(key)
|| !is_retained_selection_copy_key(&key)
|| !self
.state
.selection
@ -46,7 +48,10 @@ impl App {
return false;
}
self.suppressed_repeat_keys.insert((source_id, key.code));
self.input_leases.insert_consumed(
InputLeaseKey::new(source_id, &key),
ConsumedInputLease::SuppressRepeats,
);
true
}
}
@ -132,11 +137,19 @@ mod tests {
assert_visible_selection(&app);
assert!(app.event_rx.try_recv().is_err());
let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
.with_windows_record(crate::input::WindowsKeyRecord {
key_down: true,
repeat_count: 1,
virtual_key_code: 0x43,
virtual_scan_code: 0x2e,
unicode: 'c' as u16,
control_key_state: 0x0008,
});
let source_id = 41;
app.route_client_events_from(
source_id,
vec![crate::raw_input::RawInputEvent::Key(ctrl_c)],
vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())],
false,
);
@ -156,29 +169,39 @@ mod tests {
app.route_client_events_from(
source_id,
vec![crate::raw_input::RawInputEvent::Key(
ctrl_c.with_kind(KeyEventKind::Repeat),
)],
vec![
crate::raw_input::RawInputEvent::Key(ctrl_c.clone()),
crate::raw_input::RawInputEvent::Key(
ctrl_c.clone().with_kind(KeyEventKind::Repeat),
),
],
false,
);
assert_eq!(app.input_leases.len(), 1);
assert!(app.event_rx.try_recv().is_err());
assert!(input_rx.try_recv().is_err());
app.route_client_events_from(
source_id,
vec![crate::raw_input::RawInputEvent::Key(
ctrl_c.with_kind(KeyEventKind::Release),
ctrl_c.clone().with_kind(KeyEventKind::Release),
)],
false,
);
assert!(app.input_leases.is_empty());
app.route_client_events_from(
source_id,
vec![crate::raw_input::RawInputEvent::Key(ctrl_c)],
vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())],
false,
);
let expected = if cfg!(windows) {
b"\x1b[67;46;99;1;8;1_".as_slice()
} else {
b"\x03".as_slice()
};
assert_eq!(
input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(),
b"\x03"
expected
);
assert!(app.event_rx.try_recv().is_err());
}

View File

@ -16,7 +16,7 @@ impl App {
return;
}
self.state.update_dismissed = true;
if self.state.is_prefix_key(key) {
if self.state.is_prefix_key(&key) {
self.state.mode = Mode::Prefix;
return;
}
@ -80,7 +80,7 @@ impl AppState {
terminal_runtimes: &TerminalRuntimeRegistry,
key: TerminalKey,
) {
if self.handle_copy_mode_search_prompt_key(terminal_runtimes, key) {
if self.handle_copy_mode_search_prompt_key(terminal_runtimes, key.clone()) {
return;
}
match key.code {

375
src/app/input/lease.rs Normal file
View File

@ -0,0 +1,375 @@
use std::collections::HashMap;
use crate::app::{InputSourceId, TerminalInputContext, TerminalInputTarget};
use crate::input::{KeyIdentity, TerminalKey};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) struct InputLeaseKey {
source_id: InputSourceId,
identity: KeyIdentity,
}
impl InputLeaseKey {
pub(crate) fn new(source_id: InputSourceId, key: &TerminalKey) -> Self {
Self {
source_id,
identity: key.identity(),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct ForwardedInputLease {
pub(crate) target: TerminalInputTarget,
pub(crate) key: TerminalKey,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ConsumedInputLease {
ReprocessRepeats(TerminalInputContext),
SuppressRepeats,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum InputLease {
Forwarded(ForwardedInputLease),
Consumed(ConsumedInputLease),
}
pub(crate) enum RepeatPlan {
Forwarded(TerminalInputTarget),
Reprocess {
context: TerminalInputContext,
repetitions: u16,
tracked: bool,
},
Ignore,
}
#[derive(Default)]
pub(crate) struct InputLeaseTable {
leases: HashMap<InputLeaseKey, InputLease>,
}
impl InputLeaseTable {
pub(crate) fn normalize_press(
&mut self,
lease_key: &InputLeaseKey,
key: TerminalKey,
) -> TerminalKey {
if key.kind != crossterm::event::KeyEventKind::Press || key.generated_text.is_some() {
return key;
}
if key.has_physical_identity() && self.leases.contains_key(lease_key) {
key.with_kind(crossterm::event::KeyEventKind::Repeat)
} else {
self.leases.remove(lease_key);
key
}
}
pub(crate) fn complete_press(
&mut self,
lease_key: InputLeaseKey,
key: &TerminalKey,
initial_context: Option<&TerminalInputContext>,
resulting_context: Option<&TerminalInputContext>,
target: Option<TerminalInputTarget>,
) -> RepeatPlan {
if key.generated_text.is_some() {
return RepeatPlan::Ignore;
}
if let Some(target) = target {
self.insert_forwarded(lease_key, target, key.clone());
return RepeatPlan::Ignore;
}
if !self.leases.contains_key(&lease_key) {
let disposition = match (initial_context, resulting_context) {
(Some(initial), Some(resulting)) if initial == resulting => {
ConsumedInputLease::ReprocessRepeats(initial.clone())
}
_ => ConsumedInputLease::SuppressRepeats,
};
self.insert_consumed(lease_key, disposition);
}
match self.leases.get(&lease_key) {
Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(context)))
if key.repeat_count > 1 =>
{
RepeatPlan::Reprocess {
context: context.clone(),
repetitions: key.repeat_count - 1,
tracked: true,
}
}
_ => RepeatPlan::Ignore,
}
}
pub(crate) fn plan_repeat(
&mut self,
lease_key: InputLeaseKey,
key: &TerminalKey,
current_context: Option<&TerminalInputContext>,
) -> RepeatPlan {
match self.leases.get(&lease_key) {
Some(InputLease::Forwarded(lease)) => {
return RepeatPlan::Forwarded(lease.target.clone());
}
Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(context)))
if current_context == Some(context) =>
{
return RepeatPlan::Reprocess {
context: context.clone(),
repetitions: key.repeat_count,
tracked: true,
};
}
Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(_))) => {
self.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats);
return RepeatPlan::Ignore;
}
Some(InputLease::Consumed(ConsumedInputLease::SuppressRepeats)) => {
return RepeatPlan::Ignore;
}
None => {}
}
match current_context {
Some(context) => RepeatPlan::Reprocess {
context: context.clone(),
repetitions: key.repeat_count,
tracked: false,
},
None => RepeatPlan::Ignore,
}
}
pub(crate) fn reprocess_allowed(
&mut self,
lease_key: InputLeaseKey,
expected_context: &TerminalInputContext,
current_context: Option<&TerminalInputContext>,
tracked: bool,
) -> bool {
let allowed = current_context == Some(expected_context);
if tracked && !allowed {
self.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats);
}
allowed
}
pub(crate) fn remove_forwarded(&mut self, key: &InputLeaseKey) -> Option<ForwardedInputLease> {
match self.leases.remove(key) {
Some(InputLease::Forwarded(lease)) => Some(lease),
Some(InputLease::Consumed(_)) | None => None,
}
}
#[cfg(test)]
pub(crate) fn contains(&self, key: &InputLeaseKey) -> bool {
self.leases.contains_key(key)
}
pub(crate) fn insert_forwarded(
&mut self,
key: InputLeaseKey,
target: TerminalInputTarget,
original: TerminalKey,
) {
self.leases.insert(
key,
InputLease::Forwarded(ForwardedInputLease {
target,
key: original,
}),
);
}
pub(crate) fn insert_consumed(&mut self, key: InputLeaseKey, disposition: ConsumedInputLease) {
self.leases.insert(key, InputLease::Consumed(disposition));
}
pub(crate) fn remove(&mut self, key: &InputLeaseKey) -> Option<InputLease> {
self.leases.remove(key)
}
pub(crate) fn remove_source(&mut self, source_id: InputSourceId) -> Vec<ForwardedInputLease> {
let keys = self
.leases
.keys()
.filter(|key| key.source_id == source_id)
.copied()
.collect::<Vec<_>>();
self.remove_keys(keys)
}
pub(crate) fn remove_target(
&mut self,
target: &TerminalInputTarget,
) -> Vec<ForwardedInputLease> {
let keys = self
.leases
.iter()
.filter_map(|(key, lease)| match lease {
InputLease::Forwarded(lease) if &lease.target == target => Some(*key),
InputLease::Forwarded(_) | InputLease::Consumed(_) => None,
})
.collect::<Vec<_>>();
self.remove_keys(keys)
}
fn remove_keys(
&mut self,
keys: impl IntoIterator<Item = InputLeaseKey>,
) -> Vec<ForwardedInputLease> {
keys.into_iter()
.filter_map(|key| match self.leases.remove(&key) {
Some(InputLease::Forwarded(lease)) => Some(lease),
Some(InputLease::Consumed(_)) | None => None,
})
.collect()
}
#[cfg(test)]
pub(crate) fn is_empty(&self) -> bool {
self.leases.is_empty()
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.leases.len()
}
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyModifiers};
use super::*;
fn target() -> TerminalInputTarget {
TerminalInputTarget {
terminal_id: crate::terminal::TerminalId::alloc(),
}
}
#[test]
fn remove_source_returns_forwarded_and_discards_consumed_leases() {
let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty());
let forwarded = InputLeaseKey::new(7, &key);
let consumed = InputLeaseKey::new(
7,
&TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
);
let other_source = InputLeaseKey::new(8, &key);
let first_target = target();
let mut leases = InputLeaseTable::default();
leases.insert_forwarded(forwarded, first_target.clone(), key.clone());
leases.insert_consumed(consumed, ConsumedInputLease::SuppressRepeats);
leases.insert_forwarded(other_source, target(), key.clone());
assert_eq!(
leases.remove_source(7),
vec![ForwardedInputLease {
target: first_target,
key,
}]
);
assert_eq!(leases.len(), 1);
assert!(leases.contains(&other_source));
}
#[test]
fn remove_target_closes_only_forwarded_leases_for_that_terminal() {
let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty());
let removed_key = InputLeaseKey::new(7, &key);
let retained_key = InputLeaseKey::new(8, &key);
let removed_target = target();
let retained_target = target();
let mut leases = InputLeaseTable::default();
leases.insert_forwarded(removed_key, removed_target.clone(), key.clone());
leases.insert_forwarded(retained_key, retained_target, key.clone());
assert_eq!(
leases.remove_target(&removed_target),
vec![ForwardedInputLease {
target: removed_target,
key,
}]
);
assert_eq!(leases.len(), 1);
assert!(leases.contains(&retained_key));
}
#[test]
fn duplicate_physical_press_normalizes_for_forwarded_and_consumed_leases() {
let record = crate::input::WindowsKeyRecord {
key_down: true,
repeat_count: 1,
virtual_key_code: 65,
virtual_scan_code: 30,
unicode: 97,
control_key_state: 0,
};
let physical =
TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()).with_windows_record(record);
let lease_key = InputLeaseKey::new(7, &physical);
let mut leases = InputLeaseTable::default();
assert_eq!(
leases.normalize_press(&lease_key, physical.clone()).kind,
crossterm::event::KeyEventKind::Press
);
leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats);
assert_eq!(
leases.normalize_press(&lease_key, physical.clone()).kind,
crossterm::event::KeyEventKind::Repeat
);
leases.insert_forwarded(lease_key, target(), physical.clone());
assert_eq!(
leases.normalize_press(&lease_key, physical.clone()).kind,
crossterm::event::KeyEventKind::Repeat
);
}
#[test]
fn new_semantic_press_recomputes_consumed_repeat_disposition() {
let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_repeat_count(3);
let lease_key = InputLeaseKey::new(7, &key);
let context = TerminalInputContext::Pane;
let mut leases = InputLeaseTable::default();
leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats);
let key = leases.normalize_press(&lease_key, key);
let plan = leases.complete_press(lease_key, &key, Some(&context), Some(&context), None);
assert!(matches!(
plan,
RepeatPlan::Reprocess {
context: TerminalInputContext::Pane,
repetitions: 2,
tracked: true,
}
));
}
#[test]
fn physical_and_semantic_identities_do_not_collide() {
let record = crate::input::WindowsKeyRecord {
key_down: true,
repeat_count: 1,
virtual_key_code: 65,
virtual_scan_code: 30,
unicode: 97,
control_key_state: 0,
};
let physical =
TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()).with_windows_record(record);
let semantic = TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty());
assert_ne!(
InputLeaseKey::new(7, &physical),
InputLeaseKey::new(7, &semantic)
);
}
}

View File

@ -38,6 +38,7 @@ fn modified_url_click_modifier_matches_terminal_mouse_reporting() {
mod clipboard;
mod copy_mode;
mod lease;
mod modal;
mod mouse;
mod navigate;
@ -48,6 +49,7 @@ mod sidebar;
mod terminal;
pub(crate) use self::{
lease::{ConsumedInputLease, ForwardedInputLease, InputLeaseKey, InputLeaseTable, RepeatPlan},
modal::{
handle_global_menu_key, handle_keybind_help_key, handle_navigator_key,
insert_keybind_help_query_text, insert_navigator_search_text, insert_rename_input_text,
@ -121,6 +123,66 @@ impl App {
None
}
pub(crate) fn handle_text_commit_headless(&mut self, text: &str) {
if text.is_empty() {
return;
}
if self.state.popup_pane.is_some() {
if let Some(runtime) = self.popup_runtime() {
let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes()));
} else {
self.close_popup_pane();
}
return;
}
if self.state.mode != Mode::Terminal {
self.paste_into_active_text_input(text);
return;
}
self.state.clear_selection();
self.selection_autoscroll_deadline = None;
self.state.update_dismissed = true;
if let Some(ws_idx) = self.state.active {
if let Some(runtime) = self
.state
.focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
{
let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes()));
}
}
}
pub(super) async fn handle_text_commit(&mut self, text: String) {
if text.is_empty() {
return;
}
if self.state.popup_pane.is_some() {
if let Some(runtime) = self.popup_runtime() {
let _ = runtime.send_bytes(Bytes::from(text)).await;
} else {
self.close_popup_pane();
}
return;
}
if self.state.mode != Mode::Terminal {
self.paste_into_active_text_input(&text);
return;
}
self.state.clear_selection();
self.selection_autoscroll_deadline = None;
self.state.update_dismissed = true;
if let Some(ws_idx) = self.state.active {
if let Some(runtime) = self
.state
.focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
{
let _ = runtime.send_bytes(Bytes::from(text)).await;
}
}
}
pub(super) async fn handle_paste(&mut self, text: String) {
if self.state.popup_pane.is_some() {
if let Some(runtime) = self.popup_runtime() {

View File

@ -309,7 +309,7 @@ pub(super) fn keybind_help_back(state: &mut AppState) {
pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) {
if state.keybind_help.search_focused {
let text_char = keybind_help_text_char(key);
let text_char = keybind_help_text_char(key.clone());
match key.code {
KeyCode::Up => state.scroll_keybind_help(-1),
KeyCode::Down => state.scroll_keybind_help(1),
@ -343,13 +343,13 @@ pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) {
KeyCode::PageDown => state.scroll_keybind_help(8),
KeyCode::Home => state.keybind_help.scroll = 0,
KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(),
_ if keybind_help_text_char(key) == Some('/') => {
_ if keybind_help_text_char(key.clone()) == Some('/') => {
state.keybind_help.search_focused = true;
state.keybind_help.scroll = 0;
}
KeyCode::Esc => keybind_help_back(state),
KeyCode::Enter => leave_modal(state),
_ if keybind_help_text_char(key) == Some('?') => leave_modal(state),
_ if keybind_help_text_char(key.clone()) == Some('?') => leave_modal(state),
_ => {}
}
}
@ -712,8 +712,8 @@ pub(crate) fn handle_resize_key(state: &mut AppState, raw_key: TerminalKey) {
let key = raw_key.as_key_event();
if key.code == KeyCode::Esc
|| key.code == KeyCode::Enter
|| state.keybinds.resize_mode.matches_prefix_key(raw_key)
|| state.keybinds.resize_mode.matches_direct_key(raw_key)
|| state.keybinds.resize_mode.matches_prefix_key(&raw_key)
|| state.keybinds.resize_mode.matches_direct_key(&raw_key)
{
if state.active.is_some() {
state.mode = Mode::Terminal;
@ -1128,8 +1128,8 @@ impl App {
let key = raw_key.as_key_event();
if key.code == KeyCode::Esc
|| key.code == KeyCode::Enter
|| self.state.keybinds.resize_mode.matches_prefix_key(raw_key)
|| self.state.keybinds.resize_mode.matches_direct_key(raw_key)
|| self.state.keybinds.resize_mode.matches_prefix_key(&raw_key)
|| self.state.keybinds.resize_mode.matches_direct_key(&raw_key)
{
self.state.mode = if self.state.active.is_some() {
Mode::Terminal

View File

@ -31,14 +31,14 @@ pub(crate) fn terminal_direct_navigation_action(
pub(crate) fn terminal_direct_non_indexed_navigation_action(
state: &AppState,
key: TerminalKey,
key: &TerminalKey,
) -> Option<NavigateAction> {
non_indexed_action_for_key(state, key, BindingDispatch::Direct)
}
pub(crate) fn terminal_direct_indexed_navigation_action(
state: &AppState,
key: TerminalKey,
key: &TerminalKey,
) -> Option<NavigateAction> {
indexed_navigation_action(state, key, BindingDispatch::Direct)
}
@ -65,7 +65,7 @@ impl App {
return;
}
if self.state.is_prefix_key(raw_key) {
if self.state.is_prefix_key(&raw_key) {
if self.state.copy_mode_pane_is_focused() {
self.state.cancel_copy_mode(&self.terminal_runtimes);
}
@ -81,20 +81,20 @@ impl App {
}
if let Some(action) =
non_indexed_action_for_key(&self.state, raw_key, BindingDispatch::Prefix)
non_indexed_action_for_key(&self.state, &raw_key, BindingDispatch::Prefix)
{
self.execute_prefix_key_action(action);
return;
}
if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) {
if let Some(binding) = command_for_key(&self.state, &raw_key, BindingDispatch::Prefix) {
self.cancel_copy_mode_if_active();
self.launch_custom_command(binding, ActionContext::Prefix);
return;
}
if let Some(action) =
indexed_navigation_action(&self.state, raw_key, BindingDispatch::Prefix)
indexed_navigation_action(&self.state, &raw_key, BindingDispatch::Prefix)
{
self.execute_prefix_key_action(action);
return;
@ -128,7 +128,7 @@ impl App {
let key = raw_key.as_key_event();
self.state.update_dismissed = true;
if key.code == KeyCode::Esc || self.state.is_prefix_key(raw_key) {
if key.code == KeyCode::Esc || self.state.is_prefix_key(&raw_key) {
leave_navigate_mode(&mut self.state);
return;
}
@ -138,7 +138,7 @@ impl App {
.keybinds
.navigate
.workspace_up
.matches_direct_key(raw_key)
.matches_direct_key(&raw_key)
{
self.state.move_selected_workspace_by_visible_delta(-1);
return;
@ -148,18 +148,18 @@ impl App {
.keybinds
.navigate
.workspace_down
.matches_direct_key(raw_key)
.matches_direct_key(&raw_key)
{
self.state.move_selected_workspace_by_visible_delta(1);
return;
}
if let Some(action) = navigate_reserved_action_for_key(&self.state, raw_key) {
if let Some(action) = navigate_reserved_action_for_key(&self.state, &raw_key) {
self.execute_tui_navigate_action(action, ActionContext::Navigate);
return;
}
if let Some(action) = navigate_mode_non_indexed_action_for_key(&self.state, raw_key) {
if let Some(action) = navigate_mode_non_indexed_action_for_key(&self.state, &raw_key) {
if action == NavigateAction::EditScrollback {
self.launch_focused_scrollback_editor();
} else {
@ -169,12 +169,12 @@ impl App {
return;
}
if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) {
if let Some(binding) = command_for_key(&self.state, &raw_key, BindingDispatch::Prefix) {
self.launch_custom_command(binding, ActionContext::Navigate);
return;
}
if let Some(action) = navigate_mode_indexed_action_for_key(&self.state, raw_key) {
if let Some(action) = navigate_mode_indexed_action_for_key(&self.state, &raw_key) {
self.execute_tui_navigate_action(action, ActionContext::Navigate);
self.selection_autoscroll_deadline = None;
}
@ -773,7 +773,7 @@ impl App {
return false;
};
let bytes = rt.encode_terminal_key(key);
let bytes = rt.encode_terminal_key(key.clone());
if bytes.is_empty() || rt.try_send_bytes(Bytes::from(bytes)).is_err() {
return false;
}
@ -1136,7 +1136,7 @@ pub(crate) enum BindingDispatch {
pub(crate) fn command_for_key(
state: &AppState,
key: TerminalKey,
key: &TerminalKey,
dispatch: BindingDispatch,
) -> Option<crate::config::CustomCommandKeybind> {
state
@ -1150,7 +1150,7 @@ pub(crate) fn command_for_key(
.cloned()
}
fn unmodified_digit_for_key(key: TerminalKey) -> Option<char> {
fn unmodified_digit_for_key(key: &TerminalKey) -> Option<char> {
('1'..='9').find(|digit| {
crate::config::terminal_key_matches_combo(
key,
@ -1164,7 +1164,7 @@ fn unmodified_digit_for_key(key: TerminalKey) -> Option<char> {
#[cfg(test)]
pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKey) -> bool {
if let Some(c) = unmodified_digit_for_key(key) {
if let Some(c) = unmodified_digit_for_key(&key) {
let idx = (c as usize) - ('1' as usize);
if let Some(ws_idx) = state.workspace_at_visible_position(idx) {
state.switch_workspace(ws_idx);
@ -1203,7 +1203,12 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKe
}
}
if state.keybinds.navigate.workspace_up.matches_direct_key(key) {
if state
.keybinds
.navigate
.workspace_up
.matches_direct_key(&key)
{
state.move_selected_workspace_by_visible_delta(-1);
return true;
}
@ -1211,24 +1216,24 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKe
.keybinds
.navigate
.workspace_down
.matches_direct_key(key)
.matches_direct_key(&key)
{
state.move_selected_workspace_by_visible_delta(1);
return true;
}
if state.keybinds.navigate.pane_left.matches_direct_key(key) {
if state.keybinds.navigate.pane_left.matches_direct_key(&key) {
state.navigate_pane(NavDirection::Left);
return true;
}
if state.keybinds.navigate.pane_down.matches_direct_key(key) {
if state.keybinds.navigate.pane_down.matches_direct_key(&key) {
state.navigate_pane(NavDirection::Down);
return true;
}
if state.keybinds.navigate.pane_up.matches_direct_key(key) {
if state.keybinds.navigate.pane_up.matches_direct_key(&key) {
state.navigate_pane(NavDirection::Up);
return true;
}
if state.keybinds.navigate.pane_right.matches_direct_key(key) {
if state.keybinds.navigate.pane_right.matches_direct_key(&key) {
state.navigate_pane(NavDirection::Right);
return true;
}
@ -1236,7 +1241,7 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKe
false
}
fn navigate_reserved_action_for_key(state: &AppState, key: TerminalKey) -> Option<NavigateAction> {
fn navigate_reserved_action_for_key(state: &AppState, key: &TerminalKey) -> Option<NavigateAction> {
if let Some(c) = unmodified_digit_for_key(key) {
return Some(NavigateAction::SwitchWorkspace(
(c as usize) - ('1' as usize),
@ -1303,12 +1308,12 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
state.update_dismissed = true;
let terminal_key = TerminalKey::from(key);
if state.is_prefix_key(terminal_key) || key.code == KeyCode::Esc {
if state.is_prefix_key(&terminal_key) || key.code == KeyCode::Esc {
leave_navigate_mode(state);
return;
}
if handle_navigate_reserved_key(state, terminal_key) {
if handle_navigate_reserved_key(state, terminal_key.clone()) {
return;
}
@ -1396,7 +1401,7 @@ fn copy_mode_survives_prefix_action(action: NavigateAction) -> bool {
fn indexed_navigation_action(
state: &AppState,
key: TerminalKey,
key: &TerminalKey,
dispatch: BindingDispatch,
) -> Option<NavigateAction> {
let kb = &state.keybinds;
@ -1440,7 +1445,7 @@ fn indexed_navigation_action(
fn action_matches(
bindings: &crate::config::ActionKeybinds,
key: TerminalKey,
key: &TerminalKey,
dispatch: BindingDispatch,
) -> bool {
match dispatch {
@ -1455,13 +1460,13 @@ fn action_for_key(
key: TerminalKey,
dispatch: BindingDispatch,
) -> Option<NavigateAction> {
non_indexed_action_for_key(state, key, dispatch)
.or_else(|| indexed_navigation_action(state, key, dispatch))
non_indexed_action_for_key(state, &key, dispatch)
.or_else(|| indexed_navigation_action(state, &key, dispatch))
}
fn non_indexed_action_for_key(
state: &AppState,
key: TerminalKey,
key: &TerminalKey,
dispatch: BindingDispatch,
) -> Option<NavigateAction> {
let kb = &state.keybinds;
@ -1536,7 +1541,7 @@ fn navigate_mode_action_for_key(state: &AppState, key: TerminalKey) -> Option<Na
fn navigate_mode_non_indexed_action_for_key(
state: &AppState,
key: TerminalKey,
key: &TerminalKey,
) -> Option<NavigateAction> {
let action = non_indexed_action_for_key(state, key, BindingDispatch::Prefix)?;
if matches!(
@ -1553,7 +1558,7 @@ fn navigate_mode_non_indexed_action_for_key(
fn navigate_mode_indexed_action_for_key(
state: &AppState,
key: TerminalKey,
key: &TerminalKey,
) -> Option<NavigateAction> {
indexed_navigation_action(state, key, BindingDispatch::Prefix)
}
@ -2719,9 +2724,9 @@ command = "echo literal"
state.keybinds = config.keybinds();
let key = TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty());
assert!(command_for_key(&state, key, BindingDispatch::Prefix).is_some());
assert!(command_for_key(&state, &key, BindingDispatch::Prefix).is_some());
assert_eq!(
indexed_navigation_action(&state, key, BindingDispatch::Prefix),
indexed_navigation_action(&state, &key, BindingDispatch::Prefix),
Some(NavigateAction::SwitchWorkspace(0))
);
}

View File

@ -41,7 +41,7 @@ impl App {
source_id: InputSourceId,
key: TerminalKey,
) -> Option<TerminalInputTarget> {
match self.prepare_popup_key_forward(key) {
match self.prepare_popup_key_forward(key.clone()) {
PreparedPopupInput::NotOpen => {}
PreparedPopupInput::Consumed => return None,
PreparedPopupInput::Bytes { target, bytes } => {
@ -66,7 +66,7 @@ impl App {
key: TerminalKey,
) -> Option<PreparedPaneInput> {
let key_event = key.as_key_event();
if self.try_copy_retained_selection(source_id, key) {
if self.try_copy_retained_selection(source_id, key.clone()) {
return None;
}
@ -74,7 +74,8 @@ impl App {
self.selection_autoscroll_deadline = None;
self.state.update_dismissed = true;
if let Some(action) = super::terminal_direct_non_indexed_navigation_action(&self.state, key)
if let Some(action) =
super::terminal_direct_non_indexed_navigation_action(&self.state, &key)
{
debug!(
code = ?key_event.code,
@ -93,7 +94,7 @@ impl App {
if let Some(binding) = super::navigate::command_for_key(
&self.state,
key,
&key,
super::navigate::BindingDispatch::Direct,
) {
debug!(
@ -107,7 +108,7 @@ impl App {
return None;
}
if let Some(action) = super::terminal_direct_indexed_navigation_action(&self.state, key) {
if let Some(action) = super::terminal_direct_indexed_navigation_action(&self.state, &key) {
debug!(
code = ?key_event.code,
modifiers = ?key_event.modifiers,
@ -119,7 +120,7 @@ impl App {
return None;
}
if self.state.is_prefix_key(key) {
if self.state.is_prefix_key(&key) {
self.state.mode = Mode::Prefix;
return None;
}
@ -189,7 +190,7 @@ impl App {
rt.scroll_reset();
let protocol = rt.keyboard_protocol();
let bytes = rt.encode_terminal_key(key);
let bytes = rt.encode_terminal_key(key.clone());
if matches!(key_event.code, KeyCode::Esc)
|| key_event
@ -251,7 +252,7 @@ impl App {
return PreparedPopupInput::Consumed;
};
rt.scroll_reset();
let bytes = rt.encode_terminal_key(key);
let bytes = rt.encode_terminal_key(key.clone());
self.state.mode = Mode::Terminal;
if bytes.is_empty() {
PreparedPopupInput::Consumed
@ -316,7 +317,7 @@ impl App {
let Some(runtime) = self.terminal_input_runtime(target) else {
return false;
};
let bytes = runtime.encode_terminal_key(key);
let bytes = runtime.encode_terminal_key(key.clone());
bytes.is_empty() || runtime.try_send_bytes(Bytes::from(bytes)).is_ok()
}
@ -328,25 +329,24 @@ impl App {
let Some(runtime) = self.terminal_input_runtime(target) else {
return false;
};
let bytes = runtime.encode_terminal_key(key);
let bytes = runtime.encode_terminal_key(key.clone());
bytes.is_empty() || runtime.send_bytes(Bytes::from(bytes)).await.is_ok()
}
fn take_pressed_keys_for_source(
&mut self,
source_id: crate::app::InputSourceId,
) -> Vec<crate::app::PressedTerminalKey> {
let pressed = self
.pressed_terminal_keys
.iter()
.filter(|((id, _), _)| *id == source_id)
.map(|(_, pressed)| pressed.clone())
.collect();
self.pressed_terminal_keys
.retain(|(id, _), _| *id != source_id);
self.suppressed_repeat_keys
.retain(|(id, _)| *id != source_id);
pressed
) -> Vec<super::ForwardedInputLease> {
self.input_leases.remove_source(source_id)
}
pub(crate) fn release_input_target_headless(&mut self, target: &TerminalInputTarget) {
for pressed in self.input_leases.remove_target(target) {
let release = pressed
.key
.with_kind(crossterm::event::KeyEventKind::Release);
let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release);
}
}
pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) {
@ -375,7 +375,7 @@ impl App {
&mut self,
key: TerminalKey,
) -> Option<TerminalInputTarget> {
match self.prepare_popup_key_forward(key) {
match self.prepare_popup_key_forward(key.clone()) {
PreparedPopupInput::NotOpen => {}
PreparedPopupInput::Consumed => return None,
PreparedPopupInput::Bytes { target, bytes } => {
@ -1579,65 +1579,155 @@ mod tests {
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn page_up_scrolls_plain_shell_pane() {
fn app_with_plain_scrollback(
line_count: usize,
) -> (App, crate::layout::PaneId, crate::layout::PaneInfo) {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
ws.tabs[0].runtimes.insert(
let mut workspace = Workspace::test_new("test");
let pane_id = workspace.tabs[0].root_pane;
let pane_infos = workspace.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let pane_info = pane_infos[0].clone();
workspace.tabs[0].runtimes.insert(
pane_id,
crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
pane_info.inner_rect.width,
pane_info.inner_rect.height,
16 * 1024,
&numbered_lines_bytes(64),
&numbered_lines_bytes(line_count),
),
);
app.state.workspaces = vec![ws];
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
(app, pane_id, pane_info)
}
let start_metrics = app
.state
fn pane_scroll_offset(app: &App, pane_id: crate::layout::PaneId) -> usize {
app.state
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("initial scroll metrics");
assert_eq!(start_metrics.offset_from_bottom, 0);
.expect("pane scroll metrics")
.offset_from_bottom
}
fn physical_page_up(repeat_count: u16) -> TerminalKey {
TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()).with_windows_record(
crate::input::WindowsKeyRecord {
key_down: true,
repeat_count,
virtual_key_code: 0x21,
virtual_scan_code: 0x49,
unicode: 0,
control_key_state: 0x0100,
},
)
}
#[tokio::test]
async fn page_up_scrolls_plain_shell_pane() {
let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64);
assert_eq!(pane_scroll_offset(&app, pane_id), 0);
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
let end_metrics = app
.state
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after PageUp");
assert_eq!(
end_metrics.offset_from_bottom,
info.inner_rect.height as usize
pane_scroll_offset(&app, pane_id),
pane_info.inner_rect.height as usize
);
}
#[tokio::test]
async fn page_down_returns_to_bottom_after_page_up() {
async fn consumed_page_up_preserves_separate_and_grouped_repeats() {
let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256);
let page_up = physical_page_up(1);
app.route_client_events(
vec![
crate::raw_input::RawInputEvent::Key(page_up.clone()),
crate::raw_input::RawInputEvent::Key(
page_up.clone().with_kind(KeyEventKind::Repeat),
),
crate::raw_input::RawInputEvent::Key(
page_up.clone().with_kind(KeyEventKind::Release),
),
],
false,
);
assert_eq!(
pane_scroll_offset(&app, pane_id),
pane_info.inner_rect.height as usize * 2
);
app.route_client_events(
vec![crate::raw_input::RawInputEvent::Key(physical_page_up(3))],
false,
);
assert_eq!(
pane_scroll_offset(&app, pane_id),
pane_info.inner_rect.height as usize * 5
);
}
#[tokio::test]
async fn runtime_consumed_page_up_preserves_separate_and_grouped_repeats() {
let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256);
let page_up = physical_page_up(1);
for key in [
page_up.clone(),
page_up.clone().with_kind(KeyEventKind::Repeat),
page_up.clone().with_kind(KeyEventKind::Release),
] {
app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(key))
.await;
}
assert_eq!(
pane_scroll_offset(&app, pane_id),
pane_info.inner_rect.height as usize * 2
);
app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(physical_page_up(3)))
.await;
assert_eq!(
pane_scroll_offset(&app, pane_id),
pane_info.inner_rect.height as usize * 5
);
}
#[tokio::test]
async fn consumed_repeat_that_becomes_forwarded_acquires_pane_ownership() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let first_pane = ws.tabs[0].root_pane;
let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal);
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
let first_info = pane_infos
.iter()
.find(|info| info.id == first_pane)
.expect("first pane info");
ws.tabs[0].runtimes.insert(
pane_id,
first_pane,
crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
first_info.inner_rect.width,
first_info.inner_rect.height,
16 * 1024,
&numbered_lines_bytes(64),
&numbered_lines_bytes(128),
),
);
let (second_runtime, mut second_rx) =
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
80,
24,
0,
b"\x1b[?1h\x1b[>15u",
3,
);
ws.tabs[0].runtimes.insert(second_pane, second_runtime);
ws.tabs[0].layout.focus_pane(first_pane);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
@ -1645,107 +1735,153 @@ mod tests {
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
let after_up = app
let page_up = physical_page_up(1);
app.route_client_events(
vec![crate::raw_input::RawInputEvent::Key(page_up.clone())],
false,
);
assert!(app.state.focus_pane_in_workspace(0, second_pane));
app.route_client_events(
vec![crate::raw_input::RawInputEvent::Key(
page_up.clone().with_kind(KeyEventKind::Repeat),
)],
false,
);
assert!(app.state.focus_pane_in_workspace(0, first_pane));
app.route_client_events(
vec![
crate::raw_input::RawInputEvent::Key(
page_up.clone().with_kind(KeyEventKind::Repeat),
),
crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)),
],
false,
);
let first_repeat = second_rx.try_recv().expect("first forwarded repeat");
let second_repeat = second_rx.try_recv().expect("owned repeat");
let release = second_rx.try_recv().expect("owned release");
assert_eq!(first_repeat, second_repeat);
assert_ne!(second_repeat, release);
assert!(second_rx.try_recv().is_err());
assert!(app.input_leases.is_empty());
}
#[tokio::test]
async fn missing_popup_runtime_suppresses_grouped_and_later_repeats() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
ws.tabs[0].runtimes.insert(pane_id, runtime);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
let (popup_runtime, _popup_rx) =
crate::terminal::TerminalRuntime::test_with_channel(40, 12);
app.install_test_popup_runtime(popup_runtime);
let popup_terminal_id = app
.state
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after PageUp");
assert!(after_up.offset_from_bottom > 0);
.popup_pane
.as_ref()
.expect("popup installed")
.terminal_id
.clone();
app.terminal_runtimes.remove(&popup_terminal_id);
let key = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()).with_repeat_count(3);
app.route_client_events(
vec![
crate::raw_input::RawInputEvent::Key(key.clone()),
crate::raw_input::RawInputEvent::Key(
key.clone()
.with_kind(KeyEventKind::Repeat)
.with_repeat_count(1),
),
crate::raw_input::RawInputEvent::Key(key.with_kind(KeyEventKind::Release)),
],
false,
);
assert!(app.state.popup_pane.is_none());
assert!(input_rx.try_recv().is_err());
assert!(app.input_leases.is_empty());
}
#[tokio::test]
async fn consumed_repeat_stays_suppressed_after_context_returns() {
let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(128);
let page_up = physical_page_up(1);
app.route_client_events(
vec![crate::raw_input::RawInputEvent::Key(page_up.clone())],
false,
);
let after_press = pane_scroll_offset(&app, pane_id);
let (popup_runtime, mut popup_rx) =
crate::terminal::TerminalRuntime::test_with_channel(40, 12);
app.install_test_popup_runtime(popup_runtime);
app.route_client_events(
vec![crate::raw_input::RawInputEvent::Key(
page_up.clone().with_kind(KeyEventKind::Repeat),
)],
false,
);
app.close_popup_pane();
app.route_client_events(
vec![
crate::raw_input::RawInputEvent::Key(
page_up.clone().with_kind(KeyEventKind::Repeat),
),
crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)),
],
false,
);
assert_eq!(pane_scroll_offset(&app, pane_id), after_press);
assert!(popup_rx.try_recv().is_err());
assert!(app.input_leases.is_empty());
}
#[tokio::test]
async fn page_down_returns_to_bottom_after_page_up() {
let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64);
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
assert!(pane_scroll_offset(&app, pane_id) > 0);
app.handle_terminal_key_headless(TerminalKey::new(
KeyCode::PageDown,
KeyModifiers::empty(),
));
let after_down = app
.state
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after PageDown");
assert_eq!(after_down.offset_from_bottom, 0);
assert_eq!(pane_scroll_offset(&app, pane_id), 0);
}
#[tokio::test]
async fn page_up_release_does_not_scroll_plain_shell_pane_again() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
ws.tabs[0].runtimes.insert(
pane_id,
crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
16 * 1024,
&numbered_lines_bytes(64),
),
);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64);
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
let after_press = app
.state
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after PageUp press");
assert_eq!(
after_press.offset_from_bottom,
info.inner_rect.height as usize
);
let after_press = pane_scroll_offset(&app, pane_id);
assert_eq!(after_press, pane_info.inner_rect.height as usize);
app.handle_terminal_key_headless(
TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())
.with_kind(KeyEventKind::Release),
);
let after_release = app
.state
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after PageUp release");
assert_eq!(
after_release.offset_from_bottom,
after_press.offset_from_bottom
);
assert_eq!(pane_scroll_offset(&app, pane_id), after_press);
}
#[tokio::test]
async fn modified_page_up_does_not_host_scroll_plain_shell_pane() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
ws.tabs[0].runtimes.insert(
pane_id,
crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
16 * 1024,
&numbered_lines_bytes(64),
),
);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64);
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::CONTROL));
let metrics = app
.state
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after modified PageUp");
assert_eq!(metrics.offset_from_bottom, 0);
assert_eq!(pane_scroll_offset(&app, pane_id), 0);
}
#[tokio::test]

View File

@ -136,9 +136,7 @@ pub struct App {
pub(crate) detached_custom_command_children: Vec<std::process::Child>,
pub(crate) persist_pane_history: bool,
pub(crate) last_render_at: Option<Instant>,
pub(crate) pressed_terminal_keys:
HashMap<(InputSourceId, crossterm::event::KeyCode), PressedTerminalKey>,
pub(crate) suppressed_repeat_keys: HashSet<(InputSourceId, crossterm::event::KeyCode)>,
pub(crate) input_leases: input::InputLeaseTable,
pub render_notify: Arc<Notify>,
pub(crate) render_dirty: Arc<crate::render_signal::RenderSignal>,
pub(crate) full_redraw_pending: bool,
@ -205,21 +203,14 @@ pub(crate) struct TerminalInputTarget {
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PressedTerminalKey {
target: TerminalInputTarget,
key: crate::input::TerminalKey,
pub(crate) enum TerminalInputContext {
Pane,
Popup(crate::terminal::TerminalId),
}
pub(crate) type InputSourceId = u64;
const LOCAL_INPUT_SOURCE: InputSourceId = 0;
fn pressed_key_identity(
source_id: InputSourceId,
key: &crate::input::TerminalKey,
) -> (InputSourceId, crossterm::event::KeyCode) {
(source_id, key.code)
}
fn auto_updates_enabled(no_session: bool) -> bool {
!no_session && !cfg!(debug_assertions)
}
@ -765,8 +756,7 @@ impl App {
selection_highlight_clear_deadline: None,
persist_pane_history: config.experimental.pane_history,
last_render_at: None,
pressed_terminal_keys: HashMap::new(),
suppressed_repeat_keys: HashSet::new(),
input_leases: input::InputLeaseTable::default(),
api_rx,
event_hub,
last_focus,
@ -1587,6 +1577,73 @@ impl App {
// ---------------------------------------------------------------------------
impl App {
pub(crate) fn terminal_input_context(&self) -> Option<TerminalInputContext> {
if let Some(popup) = &self.state.popup_pane {
Some(TerminalInputContext::Popup(popup.terminal_id.clone()))
} else if self.state.mode == Mode::Terminal {
Some(TerminalInputContext::Pane)
} else {
None
}
}
fn execute_repeat_plan_headless(
&mut self,
source_id: InputSourceId,
lease_key: input::InputLeaseKey,
key: crate::input::TerminalKey,
plan: input::RepeatPlan,
) {
match plan {
input::RepeatPlan::Forwarded(target) => {
if !self.forward_terminal_key_to_target_headless(&target, key) {
self.input_leases.remove(&lease_key);
}
}
input::RepeatPlan::Reprocess {
context,
repetitions,
tracked,
} => {
let key = key
.with_kind(crossterm::event::KeyEventKind::Repeat)
.with_repeat_count(1);
let mut forwarded_target = None;
for _ in 0..repetitions {
if let Some(target) = &forwarded_target {
if !self.forward_terminal_key_to_target_headless(target, key.clone()) {
self.input_leases.remove(&lease_key);
break;
}
continue;
}
let current_context = self.terminal_input_context();
if !self.input_leases.reprocess_allowed(
lease_key,
&context,
current_context.as_ref(),
tracked,
) {
break;
}
if let Some(target) =
self.handle_terminal_key_headless_from(source_id, key.clone())
{
if tracked {
self.input_leases.insert_forwarded(
lease_key,
target.clone(),
key.clone(),
);
forwarded_target = Some(target);
}
}
}
}
input::RepeatPlan::Ignore => {}
}
}
/// Routes raw input bytes from a client through the existing input pipeline.
///
/// The input bytes are parsed into `RawInputEvent`s and then processed.
@ -1618,57 +1675,47 @@ impl App {
let previous_mode = self.state.mode;
match event {
crate::raw_input::RawInputEvent::Key(key) => {
let pressed_key_id = pressed_key_identity(source_id, &key);
let lease_key = input::InputLeaseKey::new(source_id, &key);
let key = self.input_leases.normalize_press(&lease_key, key);
match key.kind {
crossterm::event::KeyEventKind::Press => {
if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal
{
self.suppressed_repeat_keys.remove(&pressed_key_id);
if let Some(target) =
self.handle_terminal_key_headless_from(source_id, key)
{
if !key.is_text_commit {
self.pressed_terminal_keys.insert(
pressed_key_id,
PressedTerminalKey { target, key },
);
}
} else {
self.pressed_terminal_keys.remove(&pressed_key_id);
}
let initial_context = self.terminal_input_context();
let target = if initial_context.is_some() {
self.handle_terminal_key_headless_from(source_id, key.clone())
} else {
self.pressed_terminal_keys.remove(&pressed_key_id);
self.suppressed_repeat_keys.insert(pressed_key_id);
self.handle_non_terminal_key_headless(key);
}
self.handle_non_terminal_key_headless(key.clone());
None
};
let resulting_context = self.terminal_input_context();
let plan = self.input_leases.complete_press(
lease_key,
&key,
initial_context.as_ref(),
resulting_context.as_ref(),
target,
);
self.execute_repeat_plan_headless(source_id, lease_key, key, plan);
}
crossterm::event::KeyEventKind::Repeat => {
if let Some(pressed) =
self.pressed_terminal_keys.get(&pressed_key_id).cloned()
{
if !self
.forward_terminal_key_to_target_headless(&pressed.target, key)
{
self.pressed_terminal_keys.remove(&pressed_key_id);
}
} else if (self.state.popup_pane.is_some()
|| self.state.mode == Mode::Terminal)
&& !self.suppressed_repeat_keys.contains(&pressed_key_id)
{
let _ = self.handle_terminal_key_headless_from(source_id, key);
}
let current_context = self.terminal_input_context();
let plan = self.input_leases.plan_repeat(
lease_key,
&key,
current_context.as_ref(),
);
self.execute_repeat_plan_headless(source_id, lease_key, key, plan);
}
crossterm::event::KeyEventKind::Release => {
self.suppressed_repeat_keys.remove(&pressed_key_id);
if let Some(pressed) =
self.pressed_terminal_keys.remove(&pressed_key_id)
{
if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) {
let _ = self
.forward_terminal_key_to_target_headless(&pressed.target, key);
.forward_terminal_key_to_target_headless(&lease.target, key);
}
}
}
}
crate::raw_input::RawInputEvent::Text(text) => {
self.handle_text_commit_headless(text.as_str());
}
crate::raw_input::RawInputEvent::Mouse(mouse) => {
if self.state.popup_pane.is_some() || self.state.mouse_capture {
self.handle_mouse_event_headless(source_id, mouse);
@ -1838,6 +1885,94 @@ mod tests {
)
}
#[cfg(windows)]
#[tokio::test]
async fn native_repeats_and_releases_follow_the_pressed_pane() {
let record = crate::input::WindowsKeyRecord {
key_down: true,
repeat_count: 1,
virtual_key_code: 27,
virtual_scan_code: 1,
unicode: 27,
control_key_state: 0,
};
let key = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())
.with_windows_record(record);
let enhanced = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())
.with_windows_record(crate::input::WindowsKeyRecord {
control_key_state: 0x0100,
..record
});
assert_ne!(
input::InputLeaseKey::new(7, &key),
input::InputLeaseKey::new(7, &enhanced)
);
let mut app = test_app();
let mut workspace = Workspace::test_new("test");
let pressed_pane = workspace.focused_pane_id().unwrap();
let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
workspace.tabs[0].layout.focus_pane(pressed_pane);
let (pressed_runtime, mut pressed_rx) =
TerminalRuntime::test_with_channel_capacity(80, 24, 6);
let (other_runtime, mut other_rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 6);
workspace.tabs[0]
.runtimes
.insert(pressed_pane, pressed_runtime);
workspace.tabs[0].runtimes.insert(other_pane, other_runtime);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
let release = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())
.with_windows_record(crate::input::WindowsKeyRecord {
key_down: false,
repeat_count: 1,
unicode: 0,
..record
})
.with_kind(KeyEventKind::Release);
app.route_client_events(
vec![crate::raw_input::RawInputEvent::Key(key.clone())],
false,
);
assert!(app.state.focus_pane_in_workspace(0, other_pane));
app.route_client_events(
vec![
crate::raw_input::RawInputEvent::Key(key.clone()),
crate::raw_input::RawInputEvent::Key(key.clone().with_kind(KeyEventKind::Repeat)),
crate::raw_input::RawInputEvent::Key(release),
crate::raw_input::RawInputEvent::Key(key.clone()),
crate::raw_input::RawInputEvent::OuterFocusLost,
],
false,
);
for _ in 0..3 {
assert_eq!(
pressed_rx.try_recv().unwrap(),
bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_")
);
}
assert_eq!(
pressed_rx.try_recv().unwrap(),
bytes::Bytes::from_static(b"\x1b[27;1;0;0;0;1_")
);
assert!(pressed_rx.try_recv().is_err());
assert_eq!(
other_rx.try_recv().unwrap(),
bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_")
);
assert_eq!(
other_rx.try_recv().unwrap(),
bytes::Bytes::from_static(b"\x1b[27;1;27;0;0;1_")
);
assert!(other_rx.try_recv().is_err());
assert!(app.input_leases.is_empty());
}
fn release_notes_state() -> state::ReleaseNotesState {
state::ReleaseNotesState {
version: "0.1.0".into(),
@ -4876,6 +5011,27 @@ mod tests {
);
}
#[test]
fn explicit_text_commit_does_not_trigger_navigate_binding() {
let mut app = test_app();
app.state.workspaces = vec![Workspace::test_new("test")];
app.state.active = Some(0);
app.state.selected = 0;
app.state.detach_exits = false;
app.state.mode = Mode::Navigate;
app.route_client_events(
vec![crate::raw_input::RawInputEvent::Text(
crate::input::TextCommit::new("q"),
)],
false,
);
assert!(!app.state.detach_requested);
assert_eq!(app.state.mode, Mode::Navigate);
assert!(app.input_leases.is_empty());
}
#[test]
fn route_client_input_q_detaches_in_persistence_mode() {
let mut app = test_app();
@ -5104,6 +5260,31 @@ last_pane = "prefix+tab"
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn explicit_text_commit_bypasses_bindings_leases_and_key_encoding() {
let mut app = test_app();
let mut workspace = Workspace::test_new("test");
let focused = workspace.focused_pane_id().unwrap();
let (runtime, mut rx) =
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
workspace.tabs[0].runtimes.insert(focused, runtime);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.route_client_events(
vec![crate::raw_input::RawInputEvent::Text(
crate::input::TextCommit::new("你🙂"),
)],
false,
);
assert_eq!(rx.recv().await.unwrap().as_ref(), "你🙂".as_bytes());
assert!(rx.try_recv().is_err());
assert!(app.input_leases.is_empty());
}
#[tokio::test]
async fn committed_ime_text_bypasses_report_all_key_encoding() {
let mut app = test_app();
@ -5123,7 +5304,7 @@ last_pane = "prefix+tab"
rx.recv().await.unwrap(),
bytes::Bytes::from_static("".as_bytes())
);
assert!(app.pressed_terminal_keys.is_empty());
assert!(app.input_leases.is_empty());
}
#[tokio::test]
@ -5142,7 +5323,7 @@ last_pane = "prefix+tab"
app.route_client_input(b"A".to_vec());
assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"A"));
assert!(app.pressed_terminal_keys.is_empty());
assert!(app.input_leases.is_empty());
}
#[tokio::test]
@ -5174,7 +5355,7 @@ last_pane = "prefix+tab"
rx.try_recv().expect("physical release"),
bytes::Bytes::from_static(b"\x1b[106;1:3u")
);
assert!(app.pressed_terminal_keys.is_empty());
assert!(app.input_leases.is_empty());
}
#[tokio::test]
@ -5210,7 +5391,7 @@ last_pane = "prefix+tab"
rx.try_recv().expect("synthetic release on focus loss"),
bytes::Bytes::from_static(b"\x1b[106;1:3u")
);
assert!(app.pressed_terminal_keys.is_empty());
assert!(app.input_leases.is_empty());
}
#[tokio::test]
@ -5245,7 +5426,155 @@ last_pane = "prefix+tab"
rx.try_recv().expect("synthetic release on disconnect"),
bytes::Bytes::from_static(b"\x1b[106;1:3u")
);
assert!(app.pressed_terminal_keys.is_empty());
assert!(app.input_leases.is_empty());
}
#[tokio::test]
async fn physical_count_one_repeats_and_release_keep_the_pressed_pane() {
let mut app = test_app();
let mut workspace = Workspace::test_new("test");
let pressed_pane = workspace.focused_pane_id().unwrap();
let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
workspace.tabs[0].layout.focus_pane(pressed_pane);
let (pressed_runtime, mut pressed_rx) =
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 5);
let (other_runtime, mut other_rx) =
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
workspace.tabs[0]
.runtimes
.insert(pressed_pane, pressed_runtime);
workspace.tabs[0].runtimes.insert(other_pane, other_runtime);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
let record = crate::input::WindowsKeyRecord {
key_down: true,
repeat_count: 1,
virtual_key_code: 65,
virtual_scan_code: 30,
unicode: 97,
control_key_state: 0,
};
let roundtrip = |events: Vec<crate::protocol::ClientInputEvent>| {
let message = crate::protocol::ClientMessage::InputEvents { events };
let encoded =
bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap();
let (decoded, _): (crate::protocol::ClientMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
let crate::protocol::ClientMessage::InputEvents { events } = decoded else {
panic!("expected structured input events");
};
events
.into_iter()
.map(|event| event.to_raw_input_event())
.collect()
};
let event = |kind, record| crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('a'),
modifiers: 0,
kind,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::WindowsConsole { record },
};
app.route_client_events(
roundtrip(vec![event(crate::protocol::ClientKeyKind::Press, record)]),
false,
);
assert!(app.state.focus_pane_in_workspace(0, other_pane));
app.route_client_events(
roundtrip(vec![
event(crate::protocol::ClientKeyKind::Press, record),
event(crate::protocol::ClientKeyKind::Repeat, record),
event(
crate::protocol::ClientKeyKind::Release,
crate::input::WindowsKeyRecord {
key_down: false,
unicode: 0,
..record
},
),
]),
false,
);
for expected in [
b"\x1b[97;1:1u".as_slice(),
b"\x1b[97;1:2u".as_slice(),
b"\x1b[97;1:2u".as_slice(),
b"\x1b[97;1:3u".as_slice(),
] {
assert_eq!(pressed_rx.try_recv().unwrap().as_ref(), expected);
}
assert!(pressed_rx.try_recv().is_err());
assert!(other_rx.try_recv().is_err());
assert!(app.input_leases.is_empty());
}
#[tokio::test]
async fn grouped_physical_press_and_runtime_loss_preserve_count_then_close_the_lease() {
let mut app = test_app();
let workspace = Workspace::test_new("test");
let pane_id = workspace.focused_pane_id().unwrap();
let terminal_id = workspace.tabs[0].terminal_id(pane_id).unwrap().clone();
app.state.workspaces = vec![workspace];
app.state.ensure_test_terminals();
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
let (runtime, mut rx) =
TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
app.terminal_runtimes.insert(terminal_id.clone(), runtime);
let record = crate::input::WindowsKeyRecord {
key_down: true,
repeat_count: 3,
virtual_key_code: 65,
virtual_scan_code: 30,
unicode: 97,
control_key_state: 0,
};
let message = crate::protocol::ClientMessage::InputEvents {
events: vec![crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('a'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 3,
generated_text: None,
source: crate::protocol::ClientKeySource::WindowsConsole { record },
}],
};
let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap();
let (decoded, _): (crate::protocol::ClientMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
let crate::protocol::ClientMessage::InputEvents { events } = decoded else {
panic!("expected structured input events");
};
app.route_client_events(
events
.into_iter()
.map(|event| event.to_raw_input_event())
.collect(),
false,
);
app.shutdown_terminal_runtime(terminal_id.clone());
assert_eq!(
rx.try_recv().expect("grouped press"),
bytes::Bytes::from_static(b"\x1b[97;1:1u\x1b[97;1:2u\x1b[97;1:2u")
);
assert_eq!(
rx.try_recv()
.expect("synthetic release before runtime shutdown"),
bytes::Bytes::from_static(b"\x1b[97;1:3u")
);
assert!(rx.try_recv().is_err());
assert!(app.input_leases.is_empty());
assert!(app.terminal_runtimes.get(&terminal_id).is_none());
}
#[tokio::test]

View File

@ -26,9 +26,7 @@ impl App {
.direct_attach_resize_locks
.remove(&popup.terminal_id);
self.state.terminals.remove(&popup.terminal_id);
if let Some(runtime) = self.terminal_runtimes.remove(&popup.terminal_id) {
runtime.shutdown();
}
self.shutdown_terminal_runtime(popup.terminal_id);
self.state.mode = if self.state.active.is_some() {
Mode::Terminal
} else {

View File

@ -6,8 +6,8 @@ use std::time::Duration;
use crossterm::terminal;
use super::{
background_update_check_enabled, pressed_key_identity, App, AUTO_UPDATE_CHECK_INTERVAL,
MIN_RENDER_INTERVAL, RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL,
background_update_check_enabled, App, AUTO_UPDATE_CHECK_INTERVAL, MIN_RENDER_INTERVAL,
RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL,
};
fn retain_custom_command_after_wait(
pid: u32,
@ -30,12 +30,20 @@ impl App {
.retain_mut(|child| retain_custom_command_after_wait(child.id(), child.try_wait()));
}
pub(crate) fn shutdown_terminal_runtime(&mut self, terminal_id: crate::terminal::TerminalId) {
let target = super::TerminalInputTarget {
terminal_id: terminal_id.clone(),
};
self.release_input_target_headless(&target);
if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) {
runtime.shutdown();
}
}
pub(crate) fn shutdown_detached_terminal_runtimes(&mut self) {
let terminal_ids = std::mem::take(&mut self.state.terminal_runtime_shutdowns);
for terminal_id in terminal_ids {
if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) {
runtime.shutdown();
}
self.shutdown_terminal_runtime(terminal_id);
}
}
@ -103,6 +111,65 @@ impl App {
changed
}
async fn execute_repeat_plan(
&mut self,
lease_key: super::input::InputLeaseKey,
key: crate::input::TerminalKey,
plan: super::input::RepeatPlan,
) -> bool {
match plan {
super::input::RepeatPlan::Forwarded(target) => {
if !self.forward_terminal_key_to_target(&target, key).await {
self.input_leases.remove(&lease_key);
}
true
}
super::input::RepeatPlan::Reprocess {
context,
repetitions,
tracked,
} => {
let key = key
.with_kind(crossterm::event::KeyEventKind::Repeat)
.with_repeat_count(1);
let mut forwarded_target = None;
for _ in 0..repetitions {
if let Some(target) = &forwarded_target {
if !self
.forward_terminal_key_to_target(target, key.clone())
.await
{
self.input_leases.remove(&lease_key);
break;
}
continue;
}
let current_context = self.terminal_input_context();
if !self.input_leases.reprocess_allowed(
lease_key,
&context,
current_context.as_ref(),
tracked,
) {
break;
}
if let Some(target) = self.handle_key(key.clone()).await {
if tracked {
self.input_leases.insert_forwarded(
lease_key,
target.clone(),
key.clone(),
);
forwarded_target = Some(target);
}
}
}
true
}
super::input::RepeatPlan::Ignore => false,
}
}
pub(super) async fn handle_raw_input_event(
&mut self,
event: crate::raw_input::RawInputEvent,
@ -110,60 +177,46 @@ impl App {
let previous_mode = self.state.mode;
let changed = match event {
crate::raw_input::RawInputEvent::Key(key) => {
let pressed_key_id = pressed_key_identity(super::LOCAL_INPUT_SOURCE, &key);
let lease_key = super::input::InputLeaseKey::new(super::LOCAL_INPUT_SOURCE, &key);
let key = self.input_leases.normalize_press(&lease_key, key);
match key.kind {
crossterm::event::KeyEventKind::Press => {
if self.state.popup_pane.is_some()
|| self.state.mode == crate::app::Mode::Terminal
{
self.suppressed_repeat_keys.remove(&pressed_key_id);
} else {
self.suppressed_repeat_keys.insert(pressed_key_id);
}
if let Some(target) = self.handle_key(key).await {
if !key.is_text_commit {
self.pressed_terminal_keys.insert(
pressed_key_id,
super::PressedTerminalKey { target, key },
);
}
} else {
self.pressed_terminal_keys.remove(&pressed_key_id);
}
let initial_context = self.terminal_input_context();
let target = self.handle_key(key.clone()).await;
let resulting_context = self.terminal_input_context();
let plan = self.input_leases.complete_press(
lease_key,
&key,
initial_context.as_ref(),
resulting_context.as_ref(),
target,
);
self.execute_repeat_plan(lease_key, key, plan).await;
true
}
crossterm::event::KeyEventKind::Repeat => {
if let Some(pressed) =
self.pressed_terminal_keys.get(&pressed_key_id).cloned()
{
if !self
.forward_terminal_key_to_target(&pressed.target, key)
.await
{
self.pressed_terminal_keys.remove(&pressed_key_id);
}
true
} else if (self.state.popup_pane.is_some()
|| self.state.mode == crate::app::Mode::Terminal)
&& !self.suppressed_repeat_keys.contains(&pressed_key_id)
{
self.handle_key(key).await;
true
} else {
false
}
let current_context = self.terminal_input_context();
let plan = self.input_leases.plan_repeat(
lease_key,
&key,
current_context.as_ref(),
);
self.execute_repeat_plan(lease_key, key, plan).await
}
crossterm::event::KeyEventKind::Release => {
self.suppressed_repeat_keys.remove(&pressed_key_id);
if let Some(pressed) = self.pressed_terminal_keys.remove(&pressed_key_id) {
if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) {
let _ = self
.forward_terminal_key_to_target(&pressed.target, key)
.forward_terminal_key_to_target(&lease.target, key)
.await;
}
false
}
}
}
crate::raw_input::RawInputEvent::Text(text) => {
self.handle_text_commit(text.into_string()).await;
true
}
crate::raw_input::RawInputEvent::Paste(text) => {
self.handle_paste(text).await;
true

View File

@ -1645,7 +1645,7 @@ impl AppState {
|| self.focused_pane_requests_mouse_capture_from(terminal_runtimes)
}
pub fn is_prefix_key(&self, key: crate::input::TerminalKey) -> bool {
pub fn is_prefix_key(&self, key: &crate::input::TerminalKey) -> bool {
crate::config::terminal_key_matches_combo(key, (self.prefix_code, self.prefix_mods))
}
@ -1742,7 +1742,7 @@ pub fn key_matches(
expected_mods: KeyModifiers,
) -> bool {
crate::config::terminal_key_matches_combo(
crate::input::TerminalKey::from(*key),
&crate::input::TerminalKey::from(*key),
(expected_code, expected_mods),
)
}

View File

@ -997,14 +997,12 @@ impl App {
ws_idx: usize,
) {
for terminal_id in self.state.terminal_ids_for_workspace(ws_idx) {
if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) {
tracing::debug!(
workspace_index = ws_idx,
terminal_id = %terminal_id,
"shutting down terminal runtime before worktree removal"
);
runtime.shutdown();
}
tracing::debug!(
workspace_index = ws_idx,
terminal_id = %terminal_id,
"shutting down terminal runtime before worktree removal"
);
self.shutdown_terminal_runtime(terminal_id);
}
}
}

View File

@ -281,7 +281,16 @@ fn windows_crossterm_input_event(
code: crate::protocol::ClientKeyCode::Char(codepoint),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
} => Some(crate::protocol::ClientInputEvent::Text { codepoint }),
source,
..
} => Some(crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char(codepoint),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: Some(codepoint.to_string()),
source,
}),
event => Some(event),
}
}
@ -368,20 +377,29 @@ fn windows_client_input_event_from_raw(
event: crate::raw_input::RawInputEvent,
) -> Option<crate::protocol::ClientInputEvent> {
match event {
crate::raw_input::RawInputEvent::Key(key) if key.is_text_commit => {
let crossterm::event::KeyCode::Char(codepoint) = key.code else {
return None;
};
Some(crate::protocol::ClientInputEvent::Text { codepoint })
}
crate::raw_input::RawInputEvent::Text(text) => Some(
crate::protocol::ClientInputEvent::TextCommit(text.into_string()),
),
crate::raw_input::RawInputEvent::Key(key) => {
let code = crate::protocol::ClientKeyCode::from_crossterm(key.code)?;
let modifiers = key.modifiers.bits();
let kind = crate::protocol::ClientKeyKind::from_crossterm(key.kind);
let source = if let Some(bytes) = key.vt_bytes() {
crate::protocol::ClientKeySource::Vt {
bytes: bytes.to_vec(),
}
} else if let Some(record) = key.windows_record() {
crate::protocol::ClientKeySource::WindowsConsole { record }
} else {
crate::protocol::ClientKeySource::Synthesized
};
Some(crate::protocol::ClientInputEvent::Key {
code,
modifiers,
kind,
repeat_count: key.repeat_count,
generated_text: key.generated_text.clone(),
source,
})
}
crate::raw_input::RawInputEvent::Mouse(mouse) => {
@ -573,12 +591,19 @@ mod windows_tests {
}
#[test]
fn windows_crossterm_printable_press_is_text() {
fn windows_crossterm_printable_press_keeps_key_semantics_and_text() {
let event = Event::Key(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::empty()));
assert_eq!(
windows_crossterm_input_event(event),
Some(crate::protocol::ClientInputEvent::Text { codepoint: '' })
Some(crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('你'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: Some("".to_string()),
source: crate::protocol::ClientKeySource::Synthesized,
})
);
}
@ -673,6 +698,9 @@ mod windows_tests {
code: crate::protocol::ClientKeyCode::Char('d'),
modifiers: KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt { bytes: vec![4] },
}
);
}
@ -693,6 +721,11 @@ mod windows_tests {
code: crate::protocol::ClientKeyCode::Up,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt {
bytes: b"\x1b[A".to_vec()
},
}
);
}
@ -712,6 +745,9 @@ mod windows_tests {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
}
);
}

View File

@ -9,6 +9,7 @@ use tokio::sync::mpsc;
use super::windows_client_input_event_from_raw;
#[cfg(windows)]
use super::ClientLoopEvent;
use crate::input::WindowsKeyRecord;
#[cfg(windows)]
pub(super) fn raw_console_reader_loop(
@ -164,16 +165,6 @@ enum WindowsInputRecord {
Focus(bool),
}
#[derive(Clone, Copy, Debug)]
struct WindowsKeyRecord {
key_down: bool,
repeat_count: u16,
virtual_key_code: u16,
virtual_scan_code: u16,
unicode: u16,
control_key_state: u32,
}
#[derive(Clone, Copy, Debug)]
struct WindowsMouseRecord {
x: u16,
@ -502,6 +493,10 @@ impl WindowsInputMapper {
return Some(vec![0x1b]);
}
if record.virtual_scan_code != 0 {
return None;
}
if !record.key_down
|| record.repeat_count.max(1) != 1
|| windows_key_modifiers(record.control_key_state).bits() != 0
@ -550,6 +545,10 @@ impl WindowsInputMapper {
code,
modifiers: modifiers.bits(),
kind,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
))
}
@ -574,6 +573,35 @@ impl WindowsInputMapper {
}
let is_alt_code = Self::is_alt_code(key);
let carries_native_record = key.repeat_count != 0
&& key.virtual_key_code != 0
&& key.virtual_key_code != 0x03
&& !matches!(key.virtual_key_code, 0xe5 | 0xe7)
&& !is_alt_code
&& !(0xd800..=0xdfff).contains(&key.unicode);
if carries_native_record {
return self
.translate_semantic_key_event(key, Self::semantic_key_kind(key, is_alt_code, 0))
.map(|event| match event {
crate::protocol::ClientInputEvent::Key {
code,
modifiers,
kind,
..
} => crate::protocol::ClientInputEvent::Key {
code,
modifiers,
kind,
repeat_count: key.repeat_count.max(1),
generated_text: None,
source: crate::protocol::ClientKeySource::WindowsConsole { record: key },
},
event => event,
})
.into_iter()
.collect();
}
(0..key.repeat_count.max(1))
.filter_map(|repeat_idx| {
self.translate_semantic_key_event(
@ -625,7 +653,9 @@ impl WindowsInputMapper {
if key.virtual_key_code == 0 {
let codepoint = self.utf16_unit_to_char(key.unicode)?;
if !codepoint.is_control() {
return Some(crate::protocol::ClientInputEvent::Text { codepoint });
return Some(crate::protocol::ClientInputEvent::TextCommit(
codepoint.to_string(),
));
}
}
if modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
@ -637,6 +667,10 @@ impl WindowsInputMapper {
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: modifiers.bits(),
kind,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
});
}
@ -656,6 +690,10 @@ impl WindowsInputMapper {
code,
modifiers: modifiers.bits(),
kind,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
});
}
}
@ -667,10 +705,20 @@ impl WindowsInputMapper {
})
};
code.map(|code| crate::protocol::ClientInputEvent::Key {
code,
modifiers: modifiers.bits(),
kind,
code.map(|code| {
let generated_text = matches!(code, crate::protocol::ClientKeyCode::Char(_))
.then(|| char::from_u32(key.unicode as u32))
.flatten()
.filter(|ch| !ch.is_control())
.map(|ch| ch.to_string());
crate::protocol::ClientInputEvent::Key {
code,
modifiers: modifiers.bits(),
kind,
repeat_count: 1,
generated_text,
source: crate::protocol::ClientKeySource::Synthesized,
}
})
}
@ -1063,7 +1111,7 @@ mod tests {
key_down: true,
repeat_count,
virtual_key_code: vk,
virtual_scan_code: 0,
virtual_scan_code: 1,
unicode: 0,
control_key_state: 0,
})
@ -1114,6 +1162,38 @@ mod tests {
fn translate(
records: impl IntoIterator<Item = WindowsInputRecord>,
) -> Vec<crate::protocol::ClientInputEvent> {
semantic_only(translate_with_provenance(records))
}
fn semantic_only(
events: impl IntoIterator<Item = crate::protocol::ClientInputEvent>,
) -> Vec<crate::protocol::ClientInputEvent> {
events
.into_iter()
.map(|event| match event {
crate::protocol::ClientInputEvent::Key {
code,
modifiers,
kind,
repeat_count,
generated_text,
..
} => crate::protocol::ClientInputEvent::Key {
code,
modifiers,
kind,
repeat_count,
generated_text,
source: crate::protocol::ClientKeySource::Synthesized,
},
event => event,
})
.collect()
}
fn translate_with_provenance(
records: impl IntoIterator<Item = WindowsInputRecord>,
) -> Vec<crate::protocol::ClientInputEvent> {
let mut translator = WindowsInputTranslator::default();
records
@ -1237,10 +1317,20 @@ mod tests {
control_key_state,
});
assert_eq!(
translate(records),
vec![crate::protocol::ClientInputEvent::Text { codepoint: '' }]
);
let events = translate(records);
match events.as_slice() {
[crate::protocol::ClientInputEvent::TextCommit(text)] => {
assert_eq!(text, "");
}
[crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('你'),
repeat_count: 1,
generated_text: Some(text),
source: crate::protocol::ClientKeySource::Synthesized,
..
}] => assert_eq!(text, ""),
other => panic!("unexpected VK=0 result: {other:?}"),
}
}
}
@ -1292,6 +1382,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@ -1304,6 +1398,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@ -1344,6 +1442,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char(expected),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}],
"vk={vk:#x} unicode={unicode:#x}"
);
@ -1362,6 +1464,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
}]
);
}
@ -1378,47 +1484,86 @@ mod tests {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
}]
);
}
#[test]
fn vti_physical_escape_key_record_is_immediately_semantic() {
let mut translator = WindowsInputTranslator::default();
let record = WindowsKeyRecord {
key_down: true,
repeat_count: 1,
virtual_key_code: 0x1b,
virtual_scan_code: 0x01,
unicode: 0x1b,
control_key_state: 0,
};
assert_eq!(
translator.translate(key_vk_with_scan_unicode(0x1b, 0x01, '\x1b', 0)),
translate_with_provenance([WindowsInputRecord::Key(record)]),
vec![crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::WindowsConsole { record },
}]
);
}
#[test]
fn vti_repeated_escape_record_stays_semantic() {
fn vti_grouped_escape_down_and_up_keep_native_ownership_record() {
use crate::protocol::ClientKeyKind::{Press, Release};
let events = translate_with_provenance([
key_vk_with_repeat(0x1b, 3),
key_vk_up_with_scan_unicode(0x1b, 1, '\0', 0),
]);
assert_eq!(
translate([key_vk_with_repeat(0x1b, 3)]),
vec![
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Repeat,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Repeat,
},
]
events
.iter()
.map(|event| match event {
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Esc,
kind,
repeat_count,
source: crate::protocol::ClientKeySource::WindowsConsole { record },
..
} => (*kind, *repeat_count, record.key_down, record.repeat_count),
event => panic!("unexpected grouped event: {event:?}"),
})
.collect::<Vec<_>>(),
[(Press, 3, true, 3), (Release, 1, false, 1)]
);
}
#[test]
fn vti_ctrl_break_record_keeps_semantic_path() {
assert!(matches!(
translate_with_provenance([key_vk(0x03, 0x0008)]).as_slice(),
[crate::protocol::ClientInputEvent::Key { .. }]
));
}
#[test]
fn vti_ime_virtual_keys_keep_semantic_path() {
for vk in [0xe5, 0xe7] {
assert!(matches!(
translate_with_provenance([key_vk_with_unicode(vk, 'é', 0)]).as_slice(),
[crate::protocol::ClientInputEvent::Key {
generated_text: Some(text),
source: crate::protocol::ClientKeySource::Synthesized,
..
}] if text == "é"
));
}
}
#[test]
fn vti_modified_escape_remains_semantic() {
assert_eq!(
@ -1427,6 +1572,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@ -1445,6 +1594,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@ -1453,23 +1606,14 @@ mod tests {
fn vti_repeated_virtual_key_records_emit_repeats() {
assert_eq!(
translate([key_vk_with_repeat(0x08, 3)]),
vec![
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Backspace,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Backspace,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Repeat,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Backspace,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Repeat,
},
]
vec![crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Backspace,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 3,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@ -1481,6 +1625,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@ -1493,6 +1641,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Release,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@ -1505,6 +1657,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@ -1518,16 +1674,28 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Repeat,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Repeat,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@ -1589,11 +1757,19 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Release,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@ -1612,11 +1788,19 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Release,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@ -1633,11 +1817,19 @@ mod tests {
code: crate::protocol::ClientKeyCode::Backspace,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Backspace,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Release,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@ -1656,11 +1848,19 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Release,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@ -1678,11 +1878,19 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Release,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@ -1700,35 +1908,123 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Release,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
}
#[test]
fn vti_win32_input_mode_printable_key_becomes_char() {
let records = "\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_"
.chars()
.map(key_char);
fn vti_special_key_does_not_emit_incidental_printable_text() {
assert_eq!(
translate(records),
vec![
crate::protocol::ClientInputEvent::Text { codepoint: 'a' },
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('a'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Release,
},
]
translate_with_provenance([WindowsInputRecord::Key(WindowsKeyRecord {
key_down: true,
repeat_count: 0,
virtual_key_code: 0x0d,
virtual_scan_code: 0,
unicode: b'a'.into(),
control_key_state: 0,
})]),
vec![crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
#[test]
fn vti_win32_input_mode_printable_keys_preserve_physical_records() {
assert_eq!(
translate(win32_input_mode_encoded_record(WindowsKeyRecord {
key_down: true,
repeat_count: 1,
virtual_key_code: 0,
virtual_scan_code: 0,
unicode: b'a'.into(),
control_key_state: 0,
})),
vec![crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('a'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: Some("a".into()),
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
use crate::protocol::ClientKeyKind::{Press, Release};
for repeat_count in [1, 3] {
let mut records = win32_input_mode_encoded_record(WindowsKeyRecord {
key_down: true,
repeat_count,
virtual_key_code: 0x41,
virtual_scan_code: 30,
unicode: b'a'.into(),
control_key_state: 0,
});
records.extend(win32_input_mode_encoded_record(WindowsKeyRecord {
key_down: false,
repeat_count: 1,
virtual_key_code: 0x41,
virtual_scan_code: 30,
unicode: b'a'.into(),
control_key_state: 0,
}));
assert_eq!(
translate_with_provenance(records)
.iter()
.map(|event| match event {
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('a'),
modifiers: 0,
kind,
repeat_count: event_repeat_count,
generated_text: None,
source:
crate::protocol::ClientKeySource::WindowsConsole {
record:
WindowsKeyRecord {
repeat_count: record_repeat_count,
virtual_key_code: 0x41,
virtual_scan_code: 30,
unicode,
control_key_state: 0,
key_down,
},
},
} if *unicode == u16::from(b'a') => {
(*kind, *event_repeat_count, *key_down, *record_repeat_count)
}
event => panic!("unexpected printable key event: {event:?}"),
})
.collect::<Vec<_>>(),
[
(Press, repeat_count, true, repeat_count),
(Release, 1, false, 1)
],
"repeat_count={repeat_count}"
);
}
}
#[test]
fn vti_win32_input_mode_sequence_inside_bracketed_paste_stays_payload() {
let records = "\x1b[200~alpha\x1b[13;28;13;1;16;1_bravo\x1b[201~"
@ -1817,20 +2113,33 @@ mod tests {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::WindowsConsole {
record: WindowsKeyRecord {
key_down: true,
repeat_count: 1,
virtual_key_code: 0x1b,
virtual_scan_code: 0x02,
unicode: 0,
control_key_state: 0,
}
},
}]
);
}
#[test]
fn vti_win32_input_mode_physical_escape_is_immediately_semantic() {
let records = win32_input_mode_encoded_record(WindowsKeyRecord {
let record = WindowsKeyRecord {
key_down: true,
repeat_count: 1,
virtual_key_code: 0x1b,
virtual_scan_code: 0x01,
unicode: 0x1b,
control_key_state: 0,
});
};
let records = win32_input_mode_encoded_record(record);
let mut translator = WindowsInputTranslator::default();
assert_eq!(
@ -1842,6 +2151,9 @@ mod tests {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::WindowsConsole { record },
}]
);
}
@ -1868,6 +2180,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Up,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@ -1882,6 +2198,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
}]
);
}
@ -1897,11 +2217,19 @@ mod tests {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@ -1912,17 +2240,25 @@ mod tests {
let mut translator = WindowsInputTranslator::default();
assert!(translator.translate(key_char('\x1b')).is_empty());
assert_eq!(
translator.translate(key_vk(0x26, 0)),
semantic_only(translator.translate(key_vk(0x26, 0))),
vec![
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Up,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@ -1936,6 +2272,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@ -1948,6 +2288,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Up,
modifiers: crossterm::event::KeyModifiers::ALT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@ -1960,6 +2304,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('@'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@ -1972,6 +2320,9 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('é'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: Some("é".into()),
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@ -1989,6 +2340,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('🙂'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}

View File

@ -1841,7 +1841,7 @@ fn should_bridge_clipboard_image_paste(
events.as_slice(),
[crate::raw_input::RawInputEvent::Key(key)]
if key.kind == crossterm::event::KeyEventKind::Press
&& crate::config::terminal_key_matches_combo(*key, remote_image_paste_key)
&& crate::config::terminal_key_matches_combo(key, remote_image_paste_key)
)
}

View File

@ -158,7 +158,7 @@ impl ResolvedBinding {
key_event_matches_combo(key, self.trigger.combo())
}
fn matches_terminal_key(&self, key: TerminalKey) -> bool {
fn matches_terminal_key(&self, key: &TerminalKey) -> bool {
terminal_key_matches_combo(key, self.trigger.combo())
}
}
@ -207,13 +207,13 @@ impl ActionKeybinds {
.any(|binding| binding.trigger.is_prefix() && binding.matches_key_event(key))
}
pub fn matches_prefix_key(&self, key: TerminalKey) -> bool {
pub fn matches_prefix_key(&self, key: &TerminalKey) -> bool {
self.bindings
.iter()
.any(|binding| binding.trigger.is_prefix() && binding.matches_terminal_key(key))
}
pub fn matches_direct_key(&self, key: TerminalKey) -> bool {
pub fn matches_direct_key(&self, key: &TerminalKey) -> bool {
self.bindings
.iter()
.any(|binding| binding.trigger.is_direct() && binding.matches_terminal_key(key))
@ -263,7 +263,7 @@ pub struct IndexedKeybind {
}
impl IndexedKeybind {
pub fn matched_index(&self, key: TerminalKey) -> Option<usize> {
pub fn matched_index(&self, key: &TerminalKey) -> Option<usize> {
let combo = self.trigger.combo();
let (expected_code, _) = normalize_key_combo(combo);
let KeyCode::Char(key_number @ '1'..='9') = expected_code else {
@ -1303,7 +1303,7 @@ pub fn key_event_matches_combo(key: &KeyEvent, combo: KeyCombo) -> bool {
key_parts_match_combo(key.code, key.modifiers, None, combo)
}
pub fn terminal_key_matches_combo(key: TerminalKey, combo: KeyCombo) -> bool {
pub fn terminal_key_matches_combo(key: &TerminalKey, combo: KeyCombo) -> bool {
key_parts_match_combo(key.code, key.modifiers, key.shifted_codepoint, combo)
}
@ -1404,7 +1404,7 @@ fn shifted_number_symbol(ch: char) -> Option<char> {
.find_map(|(number, symbol)| (*symbol == ch).then_some(*number))
}
fn indexed_shifted_number_matches(key: TerminalKey, combo: KeyCombo, number: char) -> bool {
fn indexed_shifted_number_matches(key: &TerminalKey, combo: KeyCombo, number: char) -> bool {
let (expected_code, expected_modifiers) = normalize_key_combo(combo);
matches!(expected_code, KeyCode::Char(expected) if expected == number)
&& expected_modifiers.contains(KeyModifiers::SHIFT)
@ -1650,7 +1650,7 @@ close_tab = "X"
for ch in ['ğ', 'ç', 'ş', 'ı', 'é', 'ø'] {
let bindings = ActionKeybinds::prefix(&ch.to_string());
assert!(bindings
.matches_prefix_key(TerminalKey::new(KeyCode::Char(ch), KeyModifiers::empty(),)));
.matches_prefix_key(&TerminalKey::new(KeyCode::Char(ch), KeyModifiers::empty(),)));
}
}
@ -1660,7 +1660,7 @@ close_tab = "X"
{
let bindings = ActionKeybinds::prefix(&format!("shift+{base}"));
assert!(bindings.matches_prefix_key(
TerminalKey::new(KeyCode::Char(base), KeyModifiers::SHIFT)
&TerminalKey::new(KeyCode::Char(base), KeyModifiers::SHIFT)
.with_shifted_codepoint(shifted as u32)
));
}
@ -1676,20 +1676,20 @@ close_tab = "X"
fn shifted_letter_binding_matches_legacy_uppercase_key_event() {
let bindings = ActionKeybinds::prefix("shift+n");
assert!(bindings
.matches_prefix_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),)));
.matches_prefix_key(&TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),)));
}
#[test]
fn shifted_letter_direct_binding_matches_legacy_uppercase_key_event() {
let bindings = ActionKeybinds::direct("shift+n");
assert!(bindings
.matches_direct_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),)));
.matches_direct_key(&TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),)));
}
#[test]
fn shifted_letter_binding_matches_modern_modified_key_event() {
let bindings = ActionKeybinds::direct("cmd+shift+j");
assert!(bindings.matches_direct_key(TerminalKey::new(
assert!(bindings.matches_direct_key(&TerminalKey::new(
KeyCode::Char('J'),
KeyModifiers::SUPER | KeyModifiers::SHIFT,
)));
@ -1699,32 +1699,32 @@ close_tab = "X"
fn legacy_uppercase_key_event_does_not_match_unshifted_letter_binding() {
let bindings = ActionKeybinds::prefix("n");
assert!(!bindings
.matches_prefix_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),)));
.matches_prefix_key(&TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),)));
}
#[test]
fn legacy_uppercase_shift_fallback_is_limited_to_ascii_letters() {
let shifted_number = ActionKeybinds::prefix("shift+1");
assert!(!shifted_number
.matches_prefix_key(TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty(),)));
.matches_prefix_key(&TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty(),)));
let shifted_non_ascii = ActionKeybinds::prefix("shift+ö");
assert!(!shifted_non_ascii
.matches_prefix_key(TerminalKey::new(KeyCode::Char('Ö'), KeyModifiers::empty(),)));
.matches_prefix_key(&TerminalKey::new(KeyCode::Char('Ö'), KeyModifiers::empty(),)));
}
#[test]
fn shifted_tab_inputs_match_backtab_canonical_binding() {
let bindings = ActionKeybinds::prefix("shift+tab");
assert!(
bindings.matches_prefix_key(TerminalKey::new(KeyCode::BackTab, KeyModifiers::empty()))
bindings.matches_prefix_key(&TerminalKey::new(KeyCode::BackTab, KeyModifiers::empty()))
);
assert!(
bindings.matches_prefix_key(TerminalKey::new(KeyCode::BackTab, KeyModifiers::SHIFT))
bindings.matches_prefix_key(&TerminalKey::new(KeyCode::BackTab, KeyModifiers::SHIFT))
);
assert!(bindings.matches_prefix_key(TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT)));
assert!(bindings.matches_prefix_key(&TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT)));
assert!(!ActionKeybinds::prefix("tab")
.matches_prefix_key(TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT)));
.matches_prefix_key(&TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT)));
assert_eq!(
normalize_key_combo((KeyCode::Tab, KeyModifiers::CONTROL | KeyModifiers::SHIFT)),
(KeyCode::BackTab, KeyModifiers::CONTROL)
@ -1746,15 +1746,15 @@ close_tab = "X"
#[test]
fn shifted_punctuation_matches_enhanced_input() {
let help = ActionKeybinds::prefix("?");
assert!(help.matches_prefix_key(TerminalKey::new(KeyCode::Char('?'), KeyModifiers::SHIFT)));
assert!(help.matches_prefix_key(&TerminalKey::new(KeyCode::Char('?'), KeyModifiers::SHIFT)));
assert!(help.matches_prefix_key(
TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
&TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
.with_shifted_codepoint('?' as u32)
));
let bang = ActionKeybinds::prefix("!");
assert!(bang.matches_prefix_key(
TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT)
&TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT)
.with_shifted_codepoint('!' as u32)
));
}
@ -1805,12 +1805,12 @@ navigate_pane_down = "ctrl+j"
assert!(keybinds
.navigate
.workspace_up
.matches_direct_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())));
.matches_direct_key(&TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())));
assert!(keybinds.navigate.workspace_down.bindings.is_empty());
assert!(keybinds
.navigate
.pane_down
.matches_direct_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::CONTROL)));
.matches_direct_key(&TerminalKey::new(KeyCode::Char('j'), KeyModifiers::CONTROL)));
assert!(diagnostics.iter().any(|diag| {
diag.contains("kept keys.navigate_workspace_up")
&& diag.contains("disabled keys.navigate_workspace_down")
@ -1862,11 +1862,11 @@ command = "echo hi"
assert!(keybinds
.navigate
.workspace_down
.matches_direct_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty())));
.matches_direct_key(&TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty())));
assert!(keybinds
.navigate
.workspace_down
.matches_direct_key(TerminalKey::new(KeyCode::Char('f'), KeyModifiers::empty())));
.matches_direct_key(&TerminalKey::new(KeyCode::Char('f'), KeyModifiers::empty())));
assert!(!keybinds.custom_commands.is_empty());
assert!(!diagnostics.iter().any(|diag| {
diag.contains("disabled keys.navigate_workspace_down")
@ -1888,7 +1888,7 @@ navigate_pane_down = "j"
assert!(keybinds
.navigate
.pane_down
.matches_direct_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())));
.matches_direct_key(&TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())));
}
#[test]

View File

@ -16,8 +16,10 @@ pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec<u8> {
}
pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec<u8> {
if key.is_text_commit {
return encode_text_input(&key).unwrap_or_default();
if key.kind != crossterm::event::KeyEventKind::Release {
if let Some(text) = &key.generated_text {
return text.as_bytes().to_vec();
}
}
// A release event only produces bytes when the pane protocol reports event
@ -282,10 +284,7 @@ fn encode_legacy(key: TerminalKey) -> Vec<u8> {
// Alt modifier on character keys: prefix with ESC
if mods.contains(KeyModifiers::ALT) {
let inner = TerminalKey {
modifiers: mods.difference(KeyModifiers::ALT),
..key
};
let inner = key.with_modifiers(mods.difference(KeyModifiers::ALT));
let mut bytes = vec![0x1b];
bytes.extend(encode_legacy_inner(inner));
return bytes;
@ -950,6 +949,13 @@ mod tests {
encode_key(release, KeyboardProtocol::Kitty { flags: 3 }),
b"\x1b[106;1:3u"
);
let mut malformed_release = TerminalKey::from(release);
malformed_release.generated_text = Some("j".to_owned());
assert_eq!(
encode_terminal_key(malformed_release, KeyboardProtocol::Kitty { flags: 3 }),
b"\x1b[106;1:3u"
);
}
#[test]

View File

@ -9,7 +9,7 @@ pub use encode::{
#[cfg(not(windows))]
pub use model::ime_compatible_keyboard_enhancement_flags;
pub use model::{
host_modify_other_keys_mode, KeyboardProtocol, MouseProtocolEncoding, MouseProtocolMode,
TerminalKey,
host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, MouseProtocolEncoding,
MouseProtocolMode, TerminalKey, TextCommit, WindowsKeyRecord,
};
pub use parse::parse_terminal_key_sequence;

View File

@ -3,13 +3,77 @@ use crossterm::event::KeyboardEnhancementFlags;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowsKeyRecord {
pub key_down: bool,
pub repeat_count: u16,
pub virtual_key_code: u16,
pub virtual_scan_code: u16,
pub unicode: u16,
pub control_key_state: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextCommit {
text: String,
}
impl TextCommit {
pub fn new(text: impl Into<String>) -> Self {
Self { text: text.into() }
}
pub fn as_str(&self) -> &str {
&self.text
}
pub(crate) fn into_string(self) -> String {
self.text
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PhysicalKeyId(u32);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KeyIdentity {
Physical(PhysicalKeyId),
Semantic(KeyCode),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum KeySource {
Synthesized,
Vt {
bytes: Vec<u8>,
},
WindowsConsole {
record: WindowsKeyRecord,
physical_key: Option<PhysicalKeyId>,
},
}
impl WindowsKeyRecord {
fn physical_key_id(self) -> Option<PhysicalKeyId> {
const ENHANCED_KEY: u32 = 0x0100;
(self.virtual_scan_code != 0).then(|| {
PhysicalKeyId(
u32::from(self.virtual_scan_code)
| (u32::from(self.control_key_state & ENHANCED_KEY != 0) << 16),
)
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TerminalKey {
pub code: KeyCode,
pub modifiers: KeyModifiers,
pub kind: crossterm::event::KeyEventKind,
pub repeat_count: u16,
pub shifted_codepoint: Option<u32>,
pub is_text_commit: bool,
pub generated_text: Option<String>,
source: KeySource,
}
impl TerminalKey {
@ -18,36 +82,127 @@ impl TerminalKey {
code,
modifiers,
kind: crossterm::event::KeyEventKind::Press,
repeat_count: 1,
shifted_codepoint: None,
is_text_commit: false,
generated_text: None,
source: KeySource::Synthesized,
}
}
pub fn with_kind(mut self, kind: crossterm::event::KeyEventKind) -> Self {
if kind == crossterm::event::KeyEventKind::Release {
self.repeat_count = 1;
self.generated_text = None;
}
self.kind = kind;
self
}
pub fn with_repeat_count(mut self, repeat_count: u16) -> Self {
self.repeat_count = if self.kind == crossterm::event::KeyEventKind::Release {
1
} else {
repeat_count.max(1)
};
self
}
pub(crate) fn with_modifiers(mut self, modifiers: KeyModifiers) -> Self {
self.modifiers = modifiers;
self
}
#[allow(dead_code)] // Reserved for the upcoming raw input parser to preserve shifted/base key pairs.
pub fn with_shifted_codepoint(mut self, shifted_codepoint: u32) -> Self {
self.shifted_codepoint = Some(shifted_codepoint);
self
}
pub fn as_text_commit(mut self) -> Self {
pub(crate) fn with_generated_text(mut self, text: Option<String>) -> Self {
self.generated_text = if self.kind == crossterm::event::KeyEventKind::Release {
None
} else {
text
};
self
}
pub(crate) fn with_vt_bytes(mut self, bytes: Vec<u8>) -> Self {
self.source = KeySource::Vt { bytes };
self
}
pub fn with_windows_record(mut self, record: WindowsKeyRecord) -> Self {
self.repeat_count = if self.kind == crossterm::event::KeyEventKind::Release {
1
} else {
record.repeat_count.max(1)
};
self.source = KeySource::WindowsConsole {
physical_key: record.physical_key_id(),
record,
};
self
}
#[cfg(any(windows, test))]
pub(crate) fn vt_bytes(&self) -> Option<&[u8]> {
match &self.source {
KeySource::Vt { bytes } => Some(bytes),
KeySource::Synthesized | KeySource::WindowsConsole { .. } => None,
}
}
#[cfg(any(windows, test))]
pub(crate) fn windows_record(&self) -> Option<WindowsKeyRecord> {
match self.source {
KeySource::WindowsConsole { record, .. } => Some(record),
KeySource::Synthesized | KeySource::Vt { .. } => None,
}
}
pub(crate) fn identity(&self) -> KeyIdentity {
match self.source {
KeySource::WindowsConsole {
physical_key: Some(physical_key),
..
} => KeyIdentity::Physical(physical_key),
KeySource::WindowsConsole {
physical_key: None, ..
}
| KeySource::Synthesized
| KeySource::Vt { .. } => KeyIdentity::Semantic(self.code),
}
}
pub(crate) fn has_physical_identity(&self) -> bool {
matches!(
self.source,
KeySource::WindowsConsole {
physical_key: Some(_),
..
}
)
}
pub fn with_text_commit(mut self) -> Self {
let has_text_only_modifiers = match self.code {
KeyCode::Char(ch) if ch.is_ascii_uppercase() => {
KeyCode::Char(ch) if ch.is_uppercase() => {
self.modifiers == KeyModifiers::SHIFT || self.modifiers.is_empty()
}
KeyCode::Char(_) => self.modifiers.is_empty(),
_ => false,
};
self.is_text_commit =
has_text_only_modifiers && self.kind == crossterm::event::KeyEventKind::Press;
if has_text_only_modifiers && self.kind == crossterm::event::KeyEventKind::Press {
self.generated_text = match self.code {
KeyCode::Char(ch) => Some(ch.to_string()),
_ => None,
};
}
self
}
pub fn as_key_event(self) -> KeyEvent {
pub fn as_key_event(&self) -> KeyEvent {
KeyEvent::new_with_kind(self.code, self.modifiers, self.kind)
}
}
@ -168,6 +323,83 @@ pub enum MouseProtocolEncoding {
mod tests {
use super::*;
#[test]
fn native_source_exposes_typed_identity_without_changing_semantics() {
let record = WindowsKeyRecord {
key_down: true,
repeat_count: 1,
virtual_key_code: 27,
virtual_scan_code: 1,
unicode: 27,
control_key_state: 0,
};
let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_windows_record(record);
let enhanced = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_windows_record(
WindowsKeyRecord {
control_key_state: 0x0100,
..record
},
);
assert!(matches!(key.identity(), KeyIdentity::Physical(_)));
assert_ne!(key.identity(), enhanced.identity());
assert_eq!(key.code, KeyCode::Esc);
}
#[test]
fn semantic_source_uses_semantic_identity() {
let key = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
assert_eq!(key.identity(), KeyIdentity::Semantic(KeyCode::Char('x')));
assert!(!key.has_physical_identity());
assert_eq!(key.windows_record(), None);
}
#[test]
fn native_source_remains_immutable_when_canonical_phase_changes() {
let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())
.with_windows_record(WindowsKeyRecord {
key_down: true,
repeat_count: 1,
virtual_key_code: 27,
virtual_scan_code: 1,
unicode: 27,
control_key_state: 0,
})
.with_kind(crossterm::event::KeyEventKind::Release);
assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
assert_eq!(
key.windows_record().map(|record| record.key_down),
Some(true)
);
assert_eq!(key.repeat_count, 1);
}
#[test]
fn release_clears_generated_text_and_grouped_repeat_count() {
let release = TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty())
.with_generated_text(Some("a".to_owned()))
.with_repeat_count(4)
.with_kind(crossterm::event::KeyEventKind::Release);
let regrouped_release = release
.clone()
.with_repeat_count(4)
.with_generated_text(Some("ignored".to_owned()));
assert_eq!(release.generated_text, None);
assert_eq!(release.repeat_count, 1);
assert_eq!(regrouped_release.generated_text, None);
assert_eq!(regrouped_release.repeat_count, 1);
}
#[test]
fn non_ascii_uppercase_with_shift_is_committed_text() {
let key = TerminalKey::new(KeyCode::Char('É'), KeyModifiers::SHIFT).with_text_commit();
assert_eq!(key.generated_text.as_deref(), Some("É"));
}
#[test]
fn protocol_from_zero_flags_is_legacy() {
assert_eq!(

View File

@ -40,13 +40,11 @@ fn parse_kitty_key_sequence(data: &str) -> Option<TerminalKey> {
let code = kitty_codepoint_to_keycode(codepoint)?;
let kind = parse_kitty_event_type(event_type)?;
Some(TerminalKey {
code,
modifiers: key_modifiers_from_u8(modifier),
kind,
shifted_codepoint,
is_text_commit: false,
})
let mut key = TerminalKey::new(code, key_modifiers_from_u8(modifier)).with_kind(kind);
if let Some(shifted_codepoint) = shifted_codepoint {
key = key.with_shifted_codepoint(shifted_codepoint);
}
Some(key)
}
#[allow(dead_code)] // Reserved for the upcoming raw stdin parser.
@ -533,7 +531,7 @@ mod tests {
fn parse_legacy_alt_shift_letter_preserves_shift() {
let key = parse_terminal_key_sequence("\x1bA").expect("alt-shift letter should parse");
assert_terminal_key_eq(
key,
key.clone(),
KeyCode::Char('A'),
KeyModifiers::ALT | KeyModifiers::SHIFT,
crossterm::event::KeyEventKind::Press,
@ -620,7 +618,7 @@ mod tests {
fn parse_kitty_sequence_with_associated_emoji_text() {
let key = parse_terminal_key_sequence("\x1b[128512;1;128512u").unwrap();
assert_terminal_key_eq(
key,
key.clone(),
KeyCode::Char('😀'),
KeyModifiers::empty(),
crossterm::event::KeyEventKind::Press,

View File

@ -1,5 +1,5 @@
pub(super) fn ghostty_key_event_from_terminal_key(
key: crate::input::TerminalKey,
key: &crate::input::TerminalKey,
) -> Option<crate::ghostty::KeyEvent> {
let mut event = crate::ghostty::KeyEvent::new().ok()?;
event.set_action(match key.kind {
@ -32,7 +32,7 @@ pub(super) fn ghostty_key_event_from_terminal_key(
Some(event)
}
pub(super) fn ghostty_prefers_herdr_text_encoding(key: crate::input::TerminalKey) -> bool {
pub(super) fn ghostty_prefers_herdr_text_encoding(key: &crate::input::TerminalKey) -> bool {
matches!(key.code, crossterm::event::KeyCode::Char(_))
}
@ -168,7 +168,7 @@ pub(super) fn ghostty_mouse_event_from_wheel_kind(
Some(event)
}
fn ghostty_key_text(key: crate::input::TerminalKey) -> Option<String> {
fn ghostty_key_text(key: &crate::input::TerminalKey) -> Option<String> {
match key.code {
crossterm::event::KeyCode::Char(c) => Some(
key.shifted_codepoint
@ -180,7 +180,7 @@ fn ghostty_key_text(key: crate::input::TerminalKey) -> Option<String> {
}
}
fn ghostty_unshifted_codepoint(key: crate::input::TerminalKey) -> Option<u32> {
fn ghostty_unshifted_codepoint(key: &crate::input::TerminalKey) -> Option<u32> {
match key.code {
crossterm::event::KeyCode::Char(c) => Some(c as u32),
_ => None,

View File

@ -1635,22 +1635,40 @@ impl GhosttyPaneTerminal {
protocol: crate::input::KeyboardProtocol,
) -> Vec<u8> {
#[cfg(windows)]
if let Some(bytes) = crate::platform::encode_windows_conpty_shift_enter(key) {
if self.core.lock().is_ok_and(|core| {
core.terminal
.kitty_keyboard_flags()
.is_ok_and(|flags| flags == 0)
&& !core.kitty_keyboard.modify_other_keys_enabled()
}) {
if self.core.lock().is_ok_and(|core| {
core.terminal
.kitty_keyboard_flags()
.is_ok_and(|flags| flags == 0)
&& !core.kitty_keyboard.modify_other_keys_enabled()
}) {
if let Some(bytes) = crate::platform::encode_windows_conpty_fallback(&key) {
return bytes;
}
}
if ghostty_prefers_herdr_text_encoding(key) {
let repeat_count = key.repeat_count;
let first = key.with_repeat_count(1);
let mut bytes = self.encode_terminal_key_once(first.clone(), protocol);
if repeat_count > 1 && first.kind != crossterm::event::KeyEventKind::Release {
let repeated = first.with_kind(crossterm::event::KeyEventKind::Repeat);
let repeated_bytes = self.encode_terminal_key_once(repeated, protocol);
for _ in 1..repeat_count {
bytes.extend_from_slice(&repeated_bytes);
}
}
bytes
}
fn encode_terminal_key_once(
&self,
key: crate::input::TerminalKey,
protocol: crate::input::KeyboardProtocol,
) -> Vec<u8> {
if ghostty_prefers_herdr_text_encoding(&key) {
return crate::input::encode_terminal_key(key, protocol);
}
let Some(event) = ghostty_key_event_from_terminal_key(key) else {
let Some(event) = ghostty_key_event_from_terminal_key(&key) else {
return crate::input::encode_terminal_key(key, protocol);
};
@ -1659,7 +1677,8 @@ impl GhosttyPaneTerminal {
};
match encoder.encode(&event) {
Ok(bytes)
if !bytes.is_empty() && encoded_key_preserves_event_kind(&bytes, key, protocol) =>
if !bytes.is_empty()
&& encoded_key_preserves_event_kind(&bytes, &key, protocol) =>
{
bytes
}
@ -1969,7 +1988,7 @@ impl GhosttyPaneTerminal {
fn encoded_key_preserves_event_kind(
bytes: &[u8],
key: crate::input::TerminalKey,
key: &crate::input::TerminalKey,
protocol: crate::input::KeyboardProtocol,
) -> bool {
if !protocol.reports_event_types() || key.kind == crossterm::event::KeyEventKind::Press {
@ -3890,10 +3909,50 @@ mod tests {
assert_eq!(encoded, b"\x1bOA");
let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
assert_eq!(encoded, b"\x1b[27;2;13~");
}
#[test]
fn grouped_semantic_key_repeats_expand_at_the_destination() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let key = crate::input::TerminalKey::new(
crossterm::event::KeyCode::Char('x'),
crossterm::event::KeyModifiers::empty(),
)
.with_repeat_count(3);
assert_eq!(
pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy),
b"xxx"
);
}
#[test]
fn grouped_release_is_encoded_once() {
let (tx, _rx) = mpsc::channel(4);
let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
terminal.write(b"\x1b[>11u");
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let protocol = pane.keyboard_protocol().unwrap();
let release = crate::input::TerminalKey::new(
crossterm::event::KeyCode::Up,
crossterm::event::KeyModifiers::empty(),
)
.with_kind(crossterm::event::KeyEventKind::Release);
let expected = pane.encode_terminal_key(release.clone(), protocol);
assert!(!expected.is_empty());
let mut malformed_release = release;
malformed_release.repeat_count = 3;
assert_eq!(
pane.encode_terminal_key(malformed_release, protocol),
expected
);
}
#[test]
fn ghostty_key_encoder_updates_after_terminal_mode_changes() {
let (tx, _rx) = mpsc::channel(4);
@ -3933,9 +3992,9 @@ mod tests {
crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT,
);
let before = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
let before = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx);
let after = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
let after = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
assert_ne!(before, after);
assert_eq!(after, b"\x1b[13;6u");
@ -3950,7 +4009,7 @@ mod tests {
pane.process_pty_bytes(pane_id, 0, b"\x1b[>5u", &tx);
let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
assert_eq!(
pane.keyboard_protocol(),
@ -3968,7 +4027,7 @@ mod tests {
pane.seed_keyboard_protocol_flags(5);
let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
assert_eq!(
pane.keyboard_protocol(),
@ -4005,6 +4064,20 @@ mod tests {
);
}
#[cfg(windows)]
#[test]
fn windows_ghostty_default_pane_preserves_synthesized_shift_enter_fallback() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
assert_eq!(
pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy),
b"\x1b[13;28;13;1;16;1_"
);
}
#[test]
fn ghostty_modify_other_keys_mode_one_preserves_shift_enter() {
let (tx, _rx) = mpsc::channel(4);
@ -4012,14 +4085,8 @@ mod tests {
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
#[cfg(windows)]
assert_eq!(
pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy),
b"\x1b[13;28;13;1;16;1_"
);
pane.seed_history_ansi("\x1b[>4;1m");
let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
assert_eq!(encoded, b"\x1b[27;2;13~");
}
@ -4033,7 +4100,7 @@ mod tests {
pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx);
let key = crate::input::parse_terminal_key_sequence("\x1b\x7f").unwrap();
let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
assert_eq!(encoded, b"\x1b[127;3u");
}
@ -4052,7 +4119,10 @@ mod tests {
crossterm::event::KeyModifiers::CONTROL,
);
assert_eq!(
legacy.encode_terminal_key(ctrl_backspace, crate::input::KeyboardProtocol::Legacy),
legacy.encode_terminal_key(
ctrl_backspace.clone(),
crate::input::KeyboardProtocol::Legacy
),
b"\x08"
);

View File

@ -64,15 +64,33 @@ use super::{ClipboardImage, ForegroundJob, Signal};
const STILL_ACTIVE: u32 = 259;
const FOREGROUND_PROCESS_SNAPSHOT_CACHE_TTL: Duration = Duration::from_millis(250);
pub(crate) fn encode_windows_conpty_shift_enter(key: crate::input::TerminalKey) -> Option<Vec<u8>> {
/// Encode native or targeted semantic Win32 input for a compatible ConPTY destination.
pub(crate) fn encode_windows_conpty_fallback(key: &crate::input::TerminalKey) -> Option<Vec<u8>> {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
if key.code != KeyCode::Enter || key.modifiers != KeyModifiers::SHIFT {
return None;
}
let (virtual_key_code, virtual_scan_code, unicode, control_key_state) =
if let Some(record) = key.windows_record() {
(
record.virtual_key_code,
record.virtual_scan_code,
record.unicode,
record.control_key_state,
)
} else if key.code == KeyCode::Enter && key.modifiers == KeyModifiers::SHIFT {
(13, 28, 13, 16)
} else {
return None;
};
let key_down = key.kind != KeyEventKind::Release;
let repeat_count = if key_down { key.repeat_count.max(1) } else { 1 };
let key_down = !matches!(key.kind, KeyEventKind::Release);
Some(format!("\x1b[13;28;13;{};16;1_", u8::from(key_down)).into_bytes())
Some(
format!(
"\x1b[{virtual_key_code};{virtual_scan_code};{unicode};{};{control_key_state};{repeat_count}_",
u8::from(key_down),
)
.into_bytes(),
)
}
#[derive(Debug)]
@ -1389,6 +1407,53 @@ mod tests {
AllocConsole, FreeConsole, GetConsoleProcessList, GetConsoleWindow,
};
#[test]
fn windows_conpty_native_encoder_uses_canonical_phase_and_repeat_count() {
let key = crate::input::TerminalKey::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::empty(),
)
.with_windows_record(crate::input::WindowsKeyRecord {
key_down: true,
repeat_count: 3,
virtual_key_code: 27,
virtual_scan_code: 1,
unicode: 27,
control_key_state: 0,
});
assert_eq!(
super::encode_windows_conpty_fallback(&key),
Some(b"\x1b[27;1;27;1;0;3_".to_vec())
);
let mut release = key.with_kind(crossterm::event::KeyEventKind::Release);
release.repeat_count = 3;
assert_eq!(
super::encode_windows_conpty_fallback(&release),
Some(b"\x1b[27;1;27;0;0;1_".to_vec())
);
}
#[test]
fn windows_conpty_native_encoder_preserves_semantic_shift_enter_fallback() {
let shift_enter = crate::input::TerminalKey::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::SHIFT,
);
assert_eq!(
super::encode_windows_conpty_fallback(&shift_enter),
Some(b"\x1b[13;28;13;1;16;1_".to_vec())
);
assert_eq!(
super::encode_windows_conpty_fallback(&crate::input::TerminalKey::new(
crossterm::event::KeyCode::Enter,
crossterm::event::KeyModifiers::empty(),
)),
None
);
}
#[test]
fn windows_notification_text_is_null_terminated_and_unicode_safe() {
let mut destination = [u16::MAX; 6];

View File

@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
pub const PROTOCOL_VERSION: u32 = 18;
pub const PROTOCOL_VERSION: u32 = 19;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.
@ -115,10 +115,11 @@ pub enum ClientInputEvent {
code: ClientKeyCode,
modifiers: u8,
kind: ClientKeyKind,
repeat_count: u16,
generated_text: Option<String>,
source: ClientKeySource,
},
Text {
codepoint: char,
},
TextCommit(String),
Mouse {
kind: ClientMouseKind,
column: u16,
@ -132,6 +133,17 @@ pub enum ClientInputEvent {
FocusLost,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientKeySource {
Synthesized,
Vt {
bytes: Vec<u8>,
},
WindowsConsole {
record: crate::input::WindowsKeyRecord,
},
}
impl ClientKeyKind {
#[cfg(any(windows, test))]
pub(crate) fn from_crossterm(kind: crossterm::event::KeyEventKind) -> Self {
@ -261,6 +273,9 @@ impl ClientInputEvent {
code: ClientKeyCode::from_crossterm(key.code)?,
modifiers: key.modifiers.bits(),
kind: ClientKeyKind::from_crossterm(key.kind),
repeat_count: 1,
generated_text: None,
source: ClientKeySource::Synthesized,
}),
crossterm::event::Event::Mouse(mouse) => Some(Self::Mouse {
kind: ClientMouseKind::from_crossterm(mouse.kind)?,
@ -281,20 +296,28 @@ impl ClientInputEvent {
code,
modifiers,
kind,
} => crate::raw_input::RawInputEvent::Key(
crate::input::TerminalKey::new(
repeat_count,
generated_text,
source,
} => {
let mut key = crate::input::TerminalKey::new(
code.to_crossterm(),
crossterm::event::KeyModifiers::from_bits_truncate(*modifiers),
)
.with_kind(kind.to_crossterm()),
),
Self::Text { codepoint } => crate::raw_input::RawInputEvent::Key(
crate::input::TerminalKey::new(
crossterm::event::KeyCode::Char(*codepoint),
crossterm::event::KeyModifiers::empty(),
)
.as_text_commit(),
),
.with_generated_text(generated_text.clone());
key = match source {
ClientKeySource::Synthesized => key,
ClientKeySource::Vt { bytes } => key.with_vt_bytes(bytes.clone()),
ClientKeySource::WindowsConsole { record } => key.with_windows_record(*record),
};
key = key
.with_repeat_count(*repeat_count)
.with_kind(kind.to_crossterm());
crate::raw_input::RawInputEvent::Key(key)
}
Self::TextCommit(text) => {
crate::raw_input::RawInputEvent::Text(crate::input::TextCommit::new(text.clone()))
}
Self::Mouse {
kind,
column,
@ -1077,12 +1100,39 @@ mod tests {
code: ClientKeyCode::Char('N'),
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
ClientInputEvent::Key {
code: ClientKeyCode::Backspace,
modifiers: 0,
kind: ClientKeyKind::Press,
repeat_count: 3,
generated_text: None,
source: crate::protocol::ClientKeySource::Vt {
bytes: b"\x1b[127;1u".to_vec(),
},
},
ClientInputEvent::Key {
code: ClientKeyCode::Esc,
modifiers: 0,
kind: ClientKeyKind::Release,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::WindowsConsole {
record: crate::input::WindowsKeyRecord {
key_down: false,
repeat_count: 1,
virtual_key_code: 27,
virtual_scan_code: 1,
unicode: 27,
control_key_state: 0,
},
},
},
ClientInputEvent::TextCommit("你🙂".to_owned()),
ClientInputEvent::Mouse {
kind: ClientMouseKind::Down(ClientMouseButton::Left),
column: 3,
@ -1092,17 +1142,58 @@ mod tests {
],
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
// Freeze the protocol 19 input envelope before it is published.
assert_eq!(
encoded,
vec![
7, 5, 0, 15, 78, 1, 0, 1, 0, 0, 0, 0, 0, 0, 3, 0, 1, 8, 27, 91, 49, 50, 55, 59, 49,
117, 0, 14, 0, 2, 1, 0, 2, 0, 1, 27, 1, 27, 0, 1, 7, 228, 189, 160, 240, 159, 153,
130, 2, 0, 0, 3, 4, 0,
]
);
let (decoded, _): (ClientMessage, _) =
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
assert_eq!(msg, decoded);
}
#[test]
fn wire_release_cannot_restore_a_grouped_repeat_count() {
let event = ClientInputEvent::Key {
code: ClientKeyCode::Esc,
modifiers: 0,
kind: ClientKeyKind::Release,
repeat_count: 3,
generated_text: Some("ignored".to_owned()),
source: ClientKeySource::Synthesized,
};
match event.to_raw_input_event() {
crate::raw_input::RawInputEvent::Key(key) => {
assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
assert_eq!(key.repeat_count, 1);
assert_eq!(key.generated_text, None);
}
other => panic!("expected key event, got {other:?}"),
}
}
#[test]
fn client_input_events_convert_to_raw_keys() {
let record = crate::input::WindowsKeyRecord {
key_down: false,
repeat_count: 1,
virtual_key_code: 78,
virtual_scan_code: 49,
unicode: 78,
control_key_state: 16,
};
let shifted = ClientInputEvent::Key {
code: ClientKeyCode::Char('N'),
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: ClientKeySource::WindowsConsole { record },
}
.to_raw_input_event();
match shifted {
@ -1110,6 +1201,10 @@ mod tests {
assert_eq!(key.code, crossterm::event::KeyCode::Char('N'));
assert_eq!(key.modifiers, crossterm::event::KeyModifiers::SHIFT);
assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
assert_eq!(
key.windows_record().map(|record| record.key_down),
Some(false)
);
}
other => panic!("expected shifted key event, got {other:?}"),
}
@ -1118,6 +1213,10 @@ mod tests {
code: ClientKeyCode::Backspace,
modifiers: 0,
kind: ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}
.to_raw_input_event();
match backspace {

View File

@ -53,10 +53,10 @@ pub fn parse_raw_input_bytes_with_ranges(data: &[u8]) -> Vec<RawInputEventWithRa
if !buffer.is_empty() {
if buffer.as_slice() == [ESC] {
events.push(RawInputEventWithRange {
event: RawInputEvent::Key(TerminalKey::new(
crossterm::event::KeyCode::Esc,
KeyModifiers::empty(),
)),
event: RawInputEvent::Key(
TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty())
.with_vt_bytes(vec![ESC]),
),
start: offset,
len: 1,
});
@ -68,7 +68,7 @@ pub fn parse_raw_input_bytes_with_ranges(data: &[u8]) -> Vec<RawInputEventWithRa
} else if let Ok(text) = std::str::from_utf8(&buffer) {
if let Some(key) = parse_terminal_key_sequence(text) {
events.push(RawInputEventWithRange {
event: RawInputEvent::Key(key.as_text_commit()),
event: RawInputEvent::Key(key.with_text_commit().with_vt_bytes(buffer.clone())),
start: offset,
len: buffer.len(),
});
@ -94,7 +94,7 @@ pub fn parse_raw_input_bytes_sync(data: &[u8]) -> Vec<RawInputEvent> {
use std::os::fd::AsRawFd;
use tokio::sync::mpsc;
use crate::input::{parse_terminal_key_sequence, TerminalKey};
use crate::input::{parse_terminal_key_sequence, TerminalKey, TextCommit};
use crate::terminal_theme::{
parse_default_color_response, parse_palette_color_response, DefaultColorKind, HostAppearance,
RgbColor,
@ -126,6 +126,7 @@ pub(crate) fn is_complete_text_bracketed_paste(data: &[u8]) -> bool {
#[derive(Debug)]
pub enum RawInputEvent {
Key(TerminalKey),
Text(TextCommit),
Paste(String),
Mouse(MouseEvent),
OuterFocusGained,
@ -187,10 +188,10 @@ impl RawInputFramer {
.into_iter()
.filter_map(|chunk| {
if chunk.as_slice() == [ESC] {
return Some(RawInputEvent::Key(TerminalKey::new(
crossterm::event::KeyCode::Esc,
KeyModifiers::empty(),
)));
return Some(RawInputEvent::Key(
TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty())
.with_vt_bytes(chunk),
));
}
extract_one_event(&chunk).map(|(event, _consumed)| {
tracing::debug!(raw_bytes = ?chunk, event = ?event, "raw input event parsed");
@ -639,10 +640,10 @@ pub(crate) fn drain_complete_input_bytes(buffer: &mut Vec<u8>) -> Vec<Vec<u8>> {
fn flush_incomplete_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>) {
if let Some(bytes) = flush_incomplete_input_bytes(buffer) {
if bytes.as_slice() == [ESC] {
let _ = tx.blocking_send(RawInputEvent::Key(TerminalKey::new(
crossterm::event::KeyCode::Esc,
KeyModifiers::empty(),
)));
let _ = tx.blocking_send(RawInputEvent::Key(
TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty())
.with_vt_bytes(bytes),
));
return;
}
@ -752,7 +753,10 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> {
}
if let Some(key) = parse_terminal_key_sequence(seq) {
return Some((RawInputEvent::Key(key), seq_len));
return Some((
RawInputEvent::Key(key.with_vt_bytes(buffer[..seq_len].to_vec())),
seq_len,
));
}
tracing::debug!(sequence = ?seq, "dropping unsupported escape sequence");
@ -761,7 +765,9 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> {
let consumed = first_complete_utf8_char_len(buffer)?;
let text = std::str::from_utf8(&buffer[..consumed]).ok()?;
let key = parse_terminal_key_sequence(text)?.as_text_commit();
let key = parse_terminal_key_sequence(text)?
.with_text_commit()
.with_vt_bytes(buffer[..consumed].to_vec());
Some((RawInputEvent::Key(key), consumed))
}

View File

@ -383,24 +383,62 @@ enum InputEventLimit {
WithinLimits,
TooManyEvents,
PasteTooLarge { size: usize },
InputPayloadTooLarge { size: usize },
}
fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit {
if events.len() > MAX_INPUT_EVENT_BATCH {
return InputEventLimit::TooManyEvents;
}
let mut expanded_events = 0usize;
let mut paste_bytes = 0usize;
let mut input_bytes = 0usize;
for event in events {
if let ClientInputEvent::Paste { text } = event {
paste_bytes = paste_bytes.saturating_add(text.len());
expanded_events = expanded_events.saturating_add(match event {
ClientInputEvent::Key { repeat_count, .. } => usize::from((*repeat_count).max(1)),
_ => 1,
});
match event {
ClientInputEvent::Key {
repeat_count,
generated_text,
source,
..
} => {
if let Some(text) = generated_text {
input_bytes = input_bytes.saturating_add(
text.len()
.saturating_mul(usize::from((*repeat_count).max(1))),
);
}
if let crate::protocol::ClientKeySource::Vt { bytes } = source {
input_bytes = input_bytes.saturating_add(bytes.len());
}
}
ClientInputEvent::TextCommit(text) => {
input_bytes = input_bytes.saturating_add(text.len());
}
ClientInputEvent::Paste { text } => {
paste_bytes = paste_bytes.saturating_add(text.len());
}
ClientInputEvent::Mouse { .. }
| ClientInputEvent::FocusGained
| ClientInputEvent::FocusLost => {}
}
}
if paste_bytes > MAX_INPUT_PAYLOAD {
InputEventLimit::PasteTooLarge { size: paste_bytes }
} else {
if expanded_events > MAX_INPUT_EVENT_BATCH {
return InputEventLimit::TooManyEvents;
}
let payload_bytes = paste_bytes.saturating_add(input_bytes);
if payload_bytes <= MAX_INPUT_PAYLOAD {
InputEventLimit::WithinLimits
} else if input_bytes == 0 {
InputEventLimit::PasteTooLarge {
size: payload_bytes,
}
} else {
InputEventLimit::InputPayloadTooLarge {
size: payload_bytes,
}
}
}
@ -717,6 +755,17 @@ fn client_read_loop(
max: MAX_INPUT_PAYLOAD,
}
}
InputEventLimit::InputPayloadTooLarge { size } => {
warn!(
client_id,
size,
max = MAX_INPUT_PAYLOAD,
"oversized structured input payload from client, closing"
);
let _ = server_event_tx
.blocking_send(ServerEvent::ClientDisconnected { client_id });
break;
}
},
ClientMessage::ObserveTerminal { target } => {
ServerEvent::ClientObserveTerminal { client_id, target }
@ -1388,6 +1437,10 @@ new_tab = "ctrl+notakey"
code: crate::protocol::ClientKeyCode::Enter,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
ClientInputEvent::FocusGained,
];
@ -1549,6 +1602,43 @@ new_tab = "ctrl+notakey"
.expect("read thread result");
}
#[test]
fn structured_input_limits_charge_grouped_repeats_and_text_payloads() {
let grouped = ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('x'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: (MAX_INPUT_EVENT_BATCH + 1) as u16,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
};
assert_eq!(
input_event_limit(&[grouped]),
InputEventLimit::TooManyEvents
);
let repeated_text = ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('x'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: MAX_INPUT_EVENT_BATCH as u16,
generated_text: Some("x".repeat((MAX_INPUT_PAYLOAD / MAX_INPUT_EVENT_BATCH) + 1)),
source: crate::protocol::ClientKeySource::Synthesized,
};
assert!(matches!(
input_event_limit(&[repeated_text]),
InputEventLimit::InputPayloadTooLarge { size } if size > MAX_INPUT_PAYLOAD
));
let text = ClientInputEvent::TextCommit("x".repeat(MAX_INPUT_PAYLOAD + 1));
assert_eq!(
input_event_limit(&[text]),
InputEventLimit::InputPayloadTooLarge {
size: MAX_INPUT_PAYLOAD + 1
}
);
}
#[test]
fn handshake_timeout_is_within_five_second_deadline() {
// The handshake timeout must be short enough that

View File

@ -257,6 +257,7 @@ pub(crate) fn events_include_interaction(events: &[crate::raw_input::RawInputEve
matches!(
event,
crate::raw_input::RawInputEvent::Key(_)
| crate::raw_input::RawInputEvent::Text(_)
| crate::raw_input::RawInputEvent::Mouse(_)
| crate::raw_input::RawInputEvent::Paste(_)
| crate::raw_input::RawInputEvent::OuterFocusGained

View File

@ -4544,6 +4544,7 @@ fn events_for_app_routing(
}
crate::raw_input::RawInputEvent::OuterFocusLost if !source_is_foreground => None,
crate::raw_input::RawInputEvent::Key(_)
| crate::raw_input::RawInputEvent::Text(_)
| crate::raw_input::RawInputEvent::Mouse(_)
| crate::raw_input::RawInputEvent::Paste(_) => {
source_is_foreground = true;
@ -7539,6 +7540,10 @@ next_tab = ""
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}],
}));
server.foreground_client_id = Some(2);
@ -7558,7 +7563,7 @@ next_tab = ""
.expect("synthetic release from background client"),
Bytes::from_static(b"\x1b[106;1:3u")
);
assert!(server.app.pressed_terminal_keys.is_empty());
assert!(server.app.input_leases.is_empty());
}
#[tokio::test]
@ -7602,6 +7607,10 @@ next_tab = ""
code: crate::protocol::ClientKeyCode::Char('x'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Release,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::FocusLost,
],
@ -7652,6 +7661,10 @@ next_tab = ""
code: crate::protocol::ClientKeyCode::Char('x'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Release,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}],
}));
assert_eq!(server.foreground_client_id, Some(3));
@ -7863,6 +7876,10 @@ next_tab = ""
code: crate::protocol::ClientKeyCode::Enter,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}],
}));
@ -7899,6 +7916,10 @@ next_tab = ""
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}],
}));
@ -7932,6 +7953,10 @@ next_tab = ""
code: crate::protocol::ClientKeyCode::Down,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
repeat_count: 1,
generated_text: None,
source: crate::protocol::ClientKeySource::Synthesized,
}],
}));

View File

@ -304,7 +304,7 @@ fn ping_over_socket_returns_version() {
assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION"));
// Intentionally hardcoded so wire protocol bumps require updating this test.
// Changing this value means old clients/servers are no longer compatible.
assert_eq!(value["result"]["protocol"], 18);
assert_eq!(value["result"]["protocol"], 19);
cleanup_spawned_herdr(child, base);
}

View File

@ -326,7 +326,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {full_stdout}"
);
assert!(
full_stdout.contains(" protocol: 18"),
full_stdout.contains(" protocol: 19"),
"stdout: {full_stdout}"
);
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
@ -359,7 +359,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {server_stdout}"
);
assert!(
server_stdout.contains("protocol: 18"),
server_stdout.contains("protocol: 19"),
"stdout: {server_stdout}"
);
@ -371,7 +371,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {client_stdout}"
);
assert!(
client_stdout.contains("protocol: 18"),
client_stdout.contains("protocol: 19"),
"stdout: {client_stdout}"
);
assert!(
@ -381,7 +381,7 @@ fn status_commands_report_client_and_server_versions() {
let full_json = run_cli_json(&socket_path, &["status", "--json"]);
assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(full_json["client"]["protocol"], 18);
assert_eq!(full_json["client"]["protocol"], 19);
assert_eq!(full_json["server"]["status"], "running");
assert_eq!(full_json["server"]["running"], true);
assert_eq!(full_json["server"]["compatible"], true);
@ -395,12 +395,12 @@ fn status_commands_report_client_and_server_versions() {
let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]);
assert_eq!(server_json["status"], "running");
assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(server_json["protocol"], 18);
assert_eq!(server_json["protocol"], 19);
assert_eq!(server_json["compatible"], true);
let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]);
assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(client_json["protocol"], 18);
assert_eq!(client_json["protocol"], 19);
assert!(client_json["binary"]
.as_str()
.is_some_and(|path| !path.is_empty()));

View File

@ -15,7 +15,7 @@ static INIT: Once = Once::new();
static CLEANUP_GUARD: OnceLock<CleanupGuard> = OnceLock::new();
const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1);
const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid";
pub const CURRENT_PROTOCOL: u32 = 18;
pub const CURRENT_PROTOCOL: u32 = 19;
pub fn register_spawned_herdr_pid(pid: Option<u32>) {
let Some(pid) = pid else {