Skip to content

Commit aca1120

Browse files
psmuxquazardous
andcommitted
feat: add #{pane_last_special_key} format variable (#315)
Add two read-only per-pane format variables for the last non-text key received on the interactive input route: - #{pane_last_special_key} -- canonical bind-key name (Escape, Enter, Up, F9, C-c, M-a, ...) - #{pane_last_special_key_ms} -- milliseconds since it arrived Complement of #{pane_last_text_input} (#311): together they partition all interactive keys into text vs non-text. Same route contract: set only in forward_key_to_active (the injected route never updates it). Also refactors the sync-input-aware pane selection into a shared for_each_receiving_pane helper, deduplicating the text-input stamping. Includes unit tests (key classification + naming) and E2E test. Co-authored-by: David Berlioz <berliozdavid@gmail.com>
1 parent 9346519 commit aca1120

9 files changed

Lines changed: 388 additions & 28 deletions

File tree

docs/integration.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,34 @@ typing arrives on the interactive route. Consumers own all policy (e.g. treat
337337
"value < N ms" as "active"); psmux just exposes the timestamp, kept on the pane
338338
(no file, freed with the pane).
339339

340+
### Special-key route signal (`#{pane_last_special_key}`)
341+
342+
The sibling of `#{pane_last_text_input}` for **non-text** keys. Two read-only
343+
format variables describing the last key, other than printable text, that
344+
reached this pane via the interactive input route:
345+
346+
- `#{pane_last_special_key}` -- its canonical bind-key name (`Escape`, `Enter`,
347+
`Tab`, `Up`, `F9`, `C-c`, `M-a`, ...), empty until the first one.
348+
- `#{pane_last_special_key_ms}` -- milliseconds since it arrived, empty if none.
349+
350+
```powershell
351+
psmux display-message -t dev -p '#{pane_last_special_key} #{pane_last_special_key_ms}'
352+
# e.g. "Escape 320", or " " if none yet
353+
```
354+
355+
Same route contract as `#{pane_last_text_input}`:
356+
357+
- **Interactive route** (`handle_key -> forward_key_to_active`) **updates** it.
358+
- **Injected route** (`send-keys` / `send-paste` / `send-text`) does **not**.
359+
- **Scope:** every key that is *not* printable text input -- `Escape`, `Enter`,
360+
`Tab`, `Backspace`, arrows/navigation, function keys, and any `Ctrl`/`Alt`
361+
chord. Printable text goes to `#{pane_last_text_input}` instead; together the
362+
two partition all interactive keys. Names come from the same renderer
363+
`list-keys` uses.
364+
365+
Consumers own all policy (e.g. "name is `Escape` and `_ms` < N"); psmux just
366+
exposes the last key + its age, kept on the pane (no file, freed with it).
367+
340368
## Named Paste Buffers
341369

342370
psmux supports named paste buffers for structured inter-pane data exchange:

src/format.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,6 +1146,24 @@ pub fn expand_var(var: &str, app: &AppState, win_idx: usize) -> String {
11461146
None => String::new(),
11471147
}
11481148
}
1149+
// The last NON-text key received on the INTERACTIVE input route
1150+
// (handle_key), by canonical bind-key name (Escape, Enter, Up, F9,
1151+
// C-c, M-a, ...); companion _ms gives its age in ms. Empty if none yet.
1152+
// Like pane_last_text_input, the injected route (send-keys /
1153+
// send-paste / send-text) does NOT update it. A read-only route signal
1154+
// -- consumers own any policy on top.
1155+
"pane_last_special_key" => {
1156+
match target_pane().and_then(|p| p.last_special_key.as_ref()) {
1157+
Some((_, name)) => name.clone(),
1158+
None => String::new(),
1159+
}
1160+
}
1161+
"pane_last_special_key_ms" => {
1162+
match target_pane().and_then(|p| p.last_special_key.as_ref()) {
1163+
Some((t, _)) => t.elapsed().as_millis().to_string(),
1164+
None => String::new(),
1165+
}
1166+
}
11491167
"pane_active" => if fmt_pane_is_active { "1".into() } else { "0".into() },
11501168
"pane_current_command" => {
11511169
if let Some(p) = target_pane() {

src/input.rs

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1883,32 +1883,45 @@ pub(crate) fn is_text_input_key(key: &KeyEvent) -> bool {
18831883
)
18841884
}
18851885

1886+
/// Apply `f` to every pane that will RECEIVE the current interactive key --
1887+
/// every non-dead pane under sync-input, else the active pane if alive -- so the
1888+
/// route-signal timestamps match what's actually routed.
1889+
fn for_each_receiving_pane<F: FnMut(&mut Pane)>(app: &mut AppState, mut f: F) {
1890+
let sync = app.sync_input;
1891+
let win = &mut app.windows[app.active_idx];
1892+
if sync {
1893+
fn walk<F: FnMut(&mut Pane)>(node: &mut Node, f: &mut F) {
1894+
match node {
1895+
Node::Leaf(p) if !p.dead => f(p),
1896+
Node::Leaf(_) => {}
1897+
Node::Split { children, .. } => { for c in children { walk(c, f); } }
1898+
}
1899+
}
1900+
walk(&mut win.root, &mut f);
1901+
} else if let Some(p) = active_pane_mut(&mut win.root, &win.active_path) {
1902+
if !p.dead {
1903+
f(p);
1904+
}
1905+
}
1906+
}
1907+
18861908
pub fn forward_key_to_active(app: &mut AppState, key: KeyEvent) -> io::Result<()> {
1887-
// Record use of the INTERACTIVE text-input route, exposed read-only as
1888-
// `#{pane_last_text_input}`. This route is handle_key -> forward_key_to_active;
1889-
// the injected route (send-keys / send-paste / send-text -> send_text_to_active)
1890-
// does NOT pass here, so it never updates the signal. Printable text only
1891-
// (no control / Ctrl / Alt). Stamp exactly the panes that will RECEIVE the
1892-
// key — every non-dead pane under sync-input, else the active pane if alive
1893-
// — so the timestamp matches what's actually routed.
1909+
// Record use of the INTERACTIVE input route as read-only route signals:
1910+
// `#{pane_last_text_input}` (printable text) and, for every other key,
1911+
// `#{pane_last_special_key}` / `_ms` (the last non-text key -- Esc, Enter,
1912+
// arrows, function keys, Ctrl/Alt chords -- by canonical bind-key name).
1913+
// This route is handle_key -> forward_key_to_active; the injected route
1914+
// (send-keys / send-paste / send-text -> send_text_to_active) does NOT pass
1915+
// here, so it never updates these signals. for_each_receiving_pane stamps
1916+
// exactly the panes that will RECEIVE the key, so the timestamps match what
1917+
// is actually routed.
18941918
if is_text_input_key(&key) {
18951919
let now = Instant::now();
1896-
let sync = app.sync_input;
1897-
let win = &mut app.windows[app.active_idx];
1898-
if sync {
1899-
fn mark(node: &mut Node, now: Instant) {
1900-
match node {
1901-
Node::Leaf(p) if !p.dead => p.last_text_input = Some(now),
1902-
Node::Leaf(_) => {}
1903-
Node::Split { children, .. } => { for c in children { mark(c, now); } }
1904-
}
1905-
}
1906-
mark(&mut win.root, now);
1907-
} else if let Some(p) = active_pane_mut(&mut win.root, &win.active_path) {
1908-
if !p.dead {
1909-
p.last_text_input = Some(now);
1910-
}
1911-
}
1920+
for_each_receiving_pane(app, |p| p.last_text_input = Some(now));
1921+
} else {
1922+
let now = Instant::now();
1923+
let name = crate::config::format_key_binding(&(key.code, key.modifiers));
1924+
for_each_receiving_pane(app, |p| p.last_special_key = Some((now, name.clone())));
19121925
}
19131926

19141927
// On Windows, modified Enter delivery depends on the modifier:
@@ -3370,3 +3383,7 @@ mod tests_issue284_pageup_wsl;
33703383
#[cfg(test)]
33713384
#[path = "../tests-rs/test_pane_last_text_input.rs"]
33723385
mod tests_pane_last_text_input;
3386+
3387+
#[cfg(test)]
3388+
#[path = "../tests-rs/test_pane_last_special_key.rs"]
3389+
mod tests_pane_last_special_key;

src/pane.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ pub fn create_window(pty_system: &dyn portable_pty::PtySystem, app: &mut AppStat
9898
}
9999
let epoch = std::time::Instant::now() - Duration::from_secs(2);
100100
let configured_shell = if app.default_shell.is_empty() { None } else { Some(app.default_shell.as_str()) };
101-
let pane = Pane { master: wp.master, writer: wp.writer, child: wp.child, term: wp.term, last_rows: rows, last_cols: cols, id: wp.pane_id, title: hostname_cached(), title_locked: false, child_pid: wp.child_pid, data_version: wp.data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, last_text_input: None, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape: wp.cursor_shape, bell_pending: wp.bell_pending, cpr_pending: wp.cpr_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring: wp.output_ring };
101+
let pane = Pane { master: wp.master, writer: wp.writer, child: wp.child, term: wp.term, last_rows: rows, last_cols: cols, id: wp.pane_id, title: hostname_cached(), title_locked: false, child_pid: wp.child_pid, data_version: wp.data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, last_text_input: None, last_special_key: None, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape: wp.cursor_shape, bell_pending: wp.bell_pending, cpr_pending: wp.cpr_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring: wp.output_ring };
102102
let win_name = default_shell_name(None, configured_shell);
103103
let initial_pane_id = wp.pane_id;
104104
app.windows.push(Window { root: Node::Leaf(pane), active_path: vec![], name: win_name, id: app.next_win_id, activity_flag: false, bell_flag: false, silence_flag: false, last_output_time: std::time::Instant::now(), last_seen_version: 0, manual_rename: false, layout_index: 0, pane_mru: vec![initial_pane_id], zoom_saved: None, linked_from: None });
@@ -170,7 +170,7 @@ pub fn create_window(pty_system: &dyn portable_pty::PtySystem, app: &mut AppStat
170170
conpty_preemptive_dsr_response(&mut *pty_writer);
171171
let epoch = std::time::Instant::now() - Duration::from_secs(2);
172172
let pane_id = app.next_pane_id;
173-
let pane = Pane { master: pair.master, writer: pty_writer, child, term, last_rows: size.rows, last_cols: size.cols, id: pane_id, title: hostname_cached(), title_locked: false, child_pid, data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, last_text_input: None, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape, bell_pending, cpr_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring };
173+
let pane = Pane { master: pair.master, writer: pty_writer, child, term, last_rows: size.rows, last_cols: size.cols, id: pane_id, title: hostname_cached(), title_locked: false, child_pid, data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, last_text_input: None, last_special_key: None, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape, bell_pending, cpr_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring };
174174
app.next_pane_id += 1;
175175
let win_name = command.map(|c| default_shell_name(Some(c), None)).unwrap_or_else(|| default_shell_name(None, configured_shell));
176176
app.windows.push(Window { root: Node::Leaf(pane), active_path: vec![], name: win_name, id: app.next_win_id, activity_flag: false, bell_flag: false, silence_flag: false, last_output_time: std::time::Instant::now(), last_seen_version: 0, manual_rename: false, layout_index: 0, pane_mru: vec![pane_id], zoom_saved: None, linked_from: None });
@@ -286,7 +286,7 @@ pub fn create_window_raw(pty_system: &dyn portable_pty::PtySystem, app: &mut App
286286
conpty_preemptive_dsr_response(&mut *pty_writer);
287287
let epoch = std::time::Instant::now() - Duration::from_secs(2);
288288
let raw_pane_id = app.next_pane_id;
289-
let pane = Pane { master: pair.master, writer: pty_writer, child, term, last_rows: size.rows, last_cols: size.cols, id: raw_pane_id, title: hostname_cached(), title_locked: false, child_pid, data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, last_text_input: None, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape, bell_pending, cpr_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring };
289+
let pane = Pane { master: pair.master, writer: pty_writer, child, term, last_rows: size.rows, last_cols: size.cols, id: raw_pane_id, title: hostname_cached(), title_locked: false, child_pid, data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, last_text_input: None, last_special_key: None, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape, bell_pending, cpr_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring };
290290
app.next_pane_id += 1;
291291
let win_name = std::path::Path::new(&raw_args[0]).file_stem().and_then(|s| s.to_str()).unwrap_or(&raw_args[0]).to_string();
292292
app.windows.push(Window { root: Node::Leaf(pane), active_path: vec![], name: win_name, id: app.next_win_id, activity_flag: false, bell_flag: false, silence_flag: false, last_output_time: std::time::Instant::now(), last_seen_version: 0, manual_rename: false, layout_index: 0, pane_mru: vec![raw_pane_id], zoom_saved: None, linked_from: None });
@@ -389,7 +389,7 @@ pub fn split_active_with_command(app: &mut AppState, kind: LayoutKind, command:
389389
}
390390
let epoch = std::time::Instant::now() - Duration::from_secs(2);
391391
let new_pane_id = wp.pane_id;
392-
let new_leaf = Node::Leaf(Pane { master: wp.master, writer: wp.writer, child: wp.child, term: wp.term, last_rows: rows, last_cols: cols, id: new_pane_id, title: hostname_cached(), title_locked: false, child_pid: wp.child_pid, data_version: wp.data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, last_text_input: None, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape: wp.cursor_shape, bell_pending: wp.bell_pending, cpr_pending: wp.cpr_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring: wp.output_ring });
392+
let new_leaf = Node::Leaf(Pane { master: wp.master, writer: wp.writer, child: wp.child, term: wp.term, last_rows: rows, last_cols: cols, id: new_pane_id, title: hostname_cached(), title_locked: false, child_pid: wp.child_pid, data_version: wp.data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, last_text_input: None, last_special_key: None, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape: wp.cursor_shape, bell_pending: wp.bell_pending, cpr_pending: wp.cpr_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring: wp.output_ring });
393393
let win = &mut app.windows[app.active_idx];
394394
replace_leaf_with_split(&mut win.root, &win.active_path, kind, new_leaf);
395395
let mut new_path = win.active_path.clone();
@@ -442,7 +442,7 @@ pub fn split_active_with_command(app: &mut AppState, kind: LayoutKind, command:
442442
conpty_preemptive_dsr_response(&mut *pty_writer);
443443
let epoch = std::time::Instant::now() - Duration::from_secs(2);
444444
let split_pane_id = app.next_pane_id;
445-
let new_leaf = Node::Leaf(Pane { master: pair.master, writer: pty_writer, child, term, last_rows: size.rows, last_cols: size.cols, id: split_pane_id, title: hostname_cached(), title_locked: false, child_pid, data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, last_text_input: None, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape, bell_pending, cpr_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring });
445+
let new_leaf = Node::Leaf(Pane { master: pair.master, writer: pty_writer, child, term, last_rows: size.rows, last_cols: size.cols, id: split_pane_id, title: hostname_cached(), title_locked: false, child_pid, data_version, last_title_check: epoch, last_infer_title: epoch, dead: false, last_text_input: None, last_special_key: None, vt_bridge_cache: None, vti_mode_cache: None, mouse_input_cache: None, cursor_shape, bell_pending, cpr_pending, copy_state: None, pane_style: None, squelch_until: None, output_ring });
446446
app.next_pane_id += 1;
447447
let win = &mut app.windows[app.active_idx];
448448
replace_leaf_with_split(&mut win.root, &win.active_path, kind, new_leaf);

src/popup.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ pub fn create_popup_pane(
115115
last_infer_title: epoch,
116116
dead: false,
117117
last_text_input: None,
118+
last_special_key: None,
118119
vt_bridge_cache: None,
119120
vti_mode_cache: None,
120121
mouse_input_cache: None,

src/proxy_pane.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ pub fn create_proxy_pane(
264264
last_infer_title: epoch,
265265
dead: false,
266266
last_text_input: None,
267+
last_special_key: None,
267268
vt_bridge_cache: None,
268269
vti_mode_cache: None,
269270
mouse_input_cache: None,

src/types.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ pub struct Pane {
113113
/// `#{pane_last_text_input}` format variable. Lives on the pane, so it's
114114
/// freed with it (no separate lifecycle / file).
115115
pub last_text_input: Option<Instant>,
116+
/// The last NON-text key routed via the INTERACTIVE input route
117+
/// (`handle_key -> forward_key_to_active`): its canonical bind-key name
118+
/// (`Escape`, `Enter`, `Up`, `F9`, `C-c`, `M-a`, ...) + the `Instant` it
119+
/// arrived; `None` until the first one. Same route contract as
120+
/// `last_text_input` (NOT updated by the injected route). The text vs
121+
/// non-text split is `is_text_input_key`. Exposed read-only as
122+
/// `#{pane_last_special_key}` / `#{pane_last_special_key_ms}`.
123+
pub last_special_key: Option<(Instant, String)>,
116124
/// Cached VT bridge detection result (for mouse injection).
117125
/// Updated on first mouse event and refreshed every 2 seconds.
118126
pub vt_bridge_cache: Option<(Instant, bool)>,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// `#{pane_last_special_key}` / `_ms` -- the last NON-text key on the interactive
2+
// route, by canonical bind-key name. "Special" is the complement of
3+
// `is_text_input_key` (#311): everything that is not printable text input --
4+
// Escape, Enter, Tab, Backspace, arrows, function keys, and Ctrl/Alt chords.
5+
// The name comes from `format_key_binding`, the same renderer `list-keys` uses.
6+
//
7+
// The route separation itself is structural, not tested here: the injected
8+
// route (send-keys / send-paste / send-text) goes through send_text_to_active,
9+
// never forward_key_to_active, so it can't reach this signal.
10+
11+
use crate::config::format_key_binding;
12+
use crate::input::is_text_input_key;
13+
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
14+
15+
fn is_special(code: KeyCode, mods: KeyModifiers) -> bool {
16+
!is_text_input_key(&KeyEvent::new(code, mods))
17+
}
18+
fn name(code: KeyCode, mods: KeyModifiers) -> String {
19+
format_key_binding(&(code, mods))
20+
}
21+
22+
#[test]
23+
fn special_keys_are_classified_and_named() {
24+
// Each is non-text (so it routes to the special-key signal) and renders to
25+
// its canonical bind-key name.
26+
assert!(is_special(KeyCode::Esc, KeyModifiers::NONE));
27+
assert_eq!(name(KeyCode::Esc, KeyModifiers::NONE), "Escape");
28+
29+
assert!(is_special(KeyCode::Enter, KeyModifiers::NONE));
30+
assert_eq!(name(KeyCode::Enter, KeyModifiers::NONE), "Enter");
31+
32+
assert!(is_special(KeyCode::Char('c'), KeyModifiers::CONTROL));
33+
assert_eq!(name(KeyCode::Char('c'), KeyModifiers::CONTROL), "C-c");
34+
35+
assert!(is_special(KeyCode::Char('a'), KeyModifiers::ALT));
36+
assert_eq!(name(KeyCode::Char('a'), KeyModifiers::ALT), "M-a");
37+
38+
assert!(is_special(KeyCode::F(9), KeyModifiers::NONE));
39+
assert_eq!(name(KeyCode::F(9), KeyModifiers::NONE), "F9");
40+
41+
assert!(is_special(KeyCode::Up, KeyModifiers::NONE));
42+
assert_eq!(name(KeyCode::Up, KeyModifiers::NONE), "Up");
43+
}
44+
45+
#[test]
46+
fn printable_text_is_not_special() {
47+
// Plain text routes to #{pane_last_text_input}, never the special-key var.
48+
assert!(!is_special(KeyCode::Char('a'), KeyModifiers::NONE));
49+
assert!(!is_special(KeyCode::Char('Z'), KeyModifiers::SHIFT)); // capital
50+
assert!(!is_special(KeyCode::Char(' '), KeyModifiers::NONE)); // space is text
51+
}

0 commit comments

Comments
 (0)