Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
556
crates/codegen/xai-grok-pager/src/input/key.rs
Normal file
556
crates/codegen/xai-grok-pager/src/input/key.rs
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
//! Key shortcut types and the `key!()` macro.
|
||||
//!
|
||||
//! A focused subset for
|
||||
//! ergonomic key matching and test construction.
|
||||
//!
|
||||
//! ```
|
||||
//! use xai_grok_pager::input::key::key;
|
||||
//!
|
||||
//! // Simple key
|
||||
//! let q = key!('q');
|
||||
//!
|
||||
//! // Key with modifier
|
||||
//! let ctrl_c = key!('c', CONTROL);
|
||||
//! let ctrl_shift_z = key!('z', CONTROL | SHIFT);
|
||||
//!
|
||||
//! // Match against a crossterm KeyEvent
|
||||
//! use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
//! let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
||||
//! assert!(ctrl_c.matches(&event));
|
||||
//! ```
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
|
||||
/// A key + modifiers pair for matching against crossterm events.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct KeyShortcut {
|
||||
pub code: KeyCode,
|
||||
pub modifiers: KeyModifiers,
|
||||
}
|
||||
|
||||
impl KeyShortcut {
|
||||
pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
|
||||
Self { code, modifiers }.normalize_case()
|
||||
}
|
||||
|
||||
/// Simple key (no modifiers). Normalizes case.
|
||||
pub fn key(code: KeyCode) -> Self {
|
||||
Self::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
/// Key with Ctrl modifier. Normalizes case.
|
||||
pub fn ctrl(code: KeyCode) -> Self {
|
||||
Self::new(code, KeyModifiers::CONTROL)
|
||||
}
|
||||
|
||||
/// Normalize Shift+lowercase ↔ uppercase.
|
||||
fn normalize_case(mut self) -> Self {
|
||||
let c = match self.code {
|
||||
KeyCode::Char(c) => c,
|
||||
_ => return self,
|
||||
};
|
||||
if c.is_ascii_uppercase() {
|
||||
self.modifiers.insert(KeyModifiers::SHIFT);
|
||||
} else if self.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
self.code = KeyCode::Char(c.to_ascii_uppercase());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if a crossterm KeyEvent matches this shortcut.
|
||||
/// Normalizes the event's case before comparing, so both
|
||||
/// `Char('G') + NONE` and `Char('g') + SHIFT` match `key!('G')`.
|
||||
pub fn matches(&self, event: &KeyEvent) -> bool {
|
||||
if event.kind == KeyEventKind::Release {
|
||||
return false;
|
||||
}
|
||||
let normalized = Self::new(event.code, event.modifiers);
|
||||
self.code == normalized.code && self.modifiers == normalized.modifiers
|
||||
}
|
||||
|
||||
/// Build a KeyEvent (Press) for tests.
|
||||
pub fn to_key_event(self) -> KeyEvent {
|
||||
KeyEvent::new(self.code, self.modifiers)
|
||||
}
|
||||
|
||||
/// Display string for UI (shortcuts bar, etc.).
|
||||
/// Delegates to `fmt::Display`.
|
||||
pub fn display(&self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
|
||||
/// Pretty display for the all-shortcuts cheatsheet modal.
|
||||
///
|
||||
/// Uses `Ctrl+Q` style instead of the compact `ctrl-q` / `C-q` bar
|
||||
/// style. Shift is always shown explicitly (e.g. `Shift+G`,
|
||||
/// `Ctrl+Shift+P`, `Shift+Tab`).
|
||||
pub fn display_pretty(&self) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
// SUPER first, spelled per-platform like Opt/Alt (Cmd on macOS).
|
||||
if self.modifiers.contains(KeyModifiers::SUPER) {
|
||||
parts.push(
|
||||
if cfg!(target_os = "macos") {
|
||||
"Cmd"
|
||||
} else {
|
||||
"Super"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if self.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
parts.push("Ctrl".into());
|
||||
}
|
||||
if self.modifiers.contains(KeyModifiers::ALT) {
|
||||
parts.push(
|
||||
if cfg!(target_os = "macos") {
|
||||
"Opt"
|
||||
} else {
|
||||
"Alt"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let has_shift = self.modifiers.contains(KeyModifiers::SHIFT);
|
||||
if has_shift {
|
||||
parts.push("Shift".into());
|
||||
}
|
||||
// BackTab is Shift+Tab but doesn't carry SHIFT in modifiers —
|
||||
// inject "Shift" before the key name if not already present.
|
||||
if self.code == KeyCode::BackTab && !has_shift {
|
||||
parts.push("Shift".into());
|
||||
}
|
||||
parts.push(match self.code {
|
||||
KeyCode::Char(' ') => "Space".into(),
|
||||
KeyCode::Char(c) => c.to_ascii_lowercase().to_string(),
|
||||
KeyCode::Enter => "Enter".into(),
|
||||
KeyCode::Esc => "Esc".into(),
|
||||
KeyCode::Tab | KeyCode::BackTab => "Tab".into(),
|
||||
KeyCode::Backspace => "Backspace".into(),
|
||||
KeyCode::Delete => "Delete".into(),
|
||||
KeyCode::Up => "\u{2191}".into(),
|
||||
KeyCode::Down => "\u{2193}".into(),
|
||||
KeyCode::Left => "\u{2190}".into(),
|
||||
KeyCode::Right => "\u{2192}".into(),
|
||||
KeyCode::Home => "Home".into(),
|
||||
KeyCode::End => "End".into(),
|
||||
KeyCode::PageUp => "Page Up".into(),
|
||||
KeyCode::PageDown => "Page Down".into(),
|
||||
KeyCode::F(n) => format!("F{n}"),
|
||||
_ => format!("{:?}", self.code),
|
||||
});
|
||||
parts.join("+")
|
||||
}
|
||||
|
||||
/// True iff this shortcut is a bare ASCII letter (no modifiers other
|
||||
/// than SHIFT). Used by the vim-mode gate in `ActionRegistry::lookup_with_mode`
|
||||
/// to decide whether a `When::ScrollbackFocused` binding should be
|
||||
/// suppressed when vim mode is off.
|
||||
pub fn is_letter_or_shift_letter(&self) -> bool {
|
||||
let KeyCode::Char(c) = self.code else {
|
||||
return false;
|
||||
};
|
||||
if !c.is_ascii_alphabetic() {
|
||||
return false;
|
||||
}
|
||||
let mods = self.modifiers;
|
||||
mods.is_empty() || mods == KeyModifiers::SHIFT
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_paste_key(key: &KeyEvent) -> bool {
|
||||
if key!('v', CONTROL).matches(key) || key!('v', SUPER).matches(key) {
|
||||
return true;
|
||||
}
|
||||
// Windows-only escape hatch: Windows Terminal's default Ctrl+V is a
|
||||
// text-only `paste` action that silently drops image clipboards
|
||||
// (Win+Shift+S, browser "Copy Image"). Alt+V is unbound in default
|
||||
// WT profiles and reaches us as a normal keypress. macOS excluded
|
||||
// (`Opt+V` types `√`); Linux excluded (no interceptor to escape).
|
||||
// Doesn't collide with AltGr — AltGr arrives as `Ctrl+Alt`, not
|
||||
// bare `Alt`, and `KeyShortcut::matches` is exact-modifier.
|
||||
#[cfg(target_os = "windows")]
|
||||
if key!('v', ALT).matches(key) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn is_inline_paste_key(key: &KeyEvent) -> bool {
|
||||
key!('v', CONTROL | SHIFT).matches(key) || key!('v', SUPER | SHIFT).matches(key)
|
||||
}
|
||||
|
||||
/// Ctrl+Z / Cmd+Z — the textarea's undo binding. Delegates to the owning
|
||||
/// crate's predicate so the chord can never desync from what the key does.
|
||||
pub fn is_undo_key(key: &KeyEvent) -> bool {
|
||||
xai_ratatui_textarea::is_undo_input(key)
|
||||
}
|
||||
|
||||
// On Windows, AltGr arrives as Ctrl+Alt; on other platforms it's composed before reaching us.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[inline]
|
||||
pub fn is_altgr(modifiers: KeyModifiers) -> bool {
|
||||
modifiers.contains(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[inline]
|
||||
pub fn is_altgr(_modifiers: KeyModifiers) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Canonical Shift+Tab encodings: `BackTab` (most xterm-likes),
|
||||
/// `BackTab+SHIFT` (some terminals), `Tab+SHIFT` (kitty protocol, some
|
||||
/// Windows terminals). Single source of truth for the `CycleMode` /
|
||||
/// `DashboardCycleMode` ActionDefs and [`is_shift_tab`].
|
||||
pub fn shift_tab_keys() -> [KeyShortcut; 3] {
|
||||
[
|
||||
KeyShortcut::key(KeyCode::BackTab),
|
||||
KeyShortcut::new(KeyCode::BackTab, KeyModifiers::SHIFT),
|
||||
KeyShortcut::new(KeyCode::Tab, KeyModifiers::SHIFT),
|
||||
]
|
||||
}
|
||||
|
||||
/// True when the event is Shift+Tab in any encoding from
|
||||
/// [`shift_tab_keys`]. Release events never match.
|
||||
pub fn is_shift_tab(key: &KeyEvent) -> bool {
|
||||
shift_tab_keys().iter().any(|k| k.matches(key))
|
||||
}
|
||||
|
||||
pub fn is_text_input_key(key: &KeyEvent) -> bool {
|
||||
matches!(key.code, KeyCode::Char(_))
|
||||
&& (key.modifiers.is_empty()
|
||||
|| key.modifiers == KeyModifiers::SHIFT
|
||||
|| is_altgr(key.modifiers))
|
||||
}
|
||||
|
||||
impl From<KeyEvent> for KeyShortcut {
|
||||
fn from(key: KeyEvent) -> Self {
|
||||
Self::new(key.code, key.modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for KeyShortcut {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
let has_shift = self.modifiers.contains(KeyModifiers::SHIFT);
|
||||
// SUPER first, spelled per-platform like Opt/Alt (Cmd on macOS).
|
||||
if self.modifiers.contains(KeyModifiers::SUPER) {
|
||||
let sup = if cfg!(target_os = "macos") {
|
||||
"Cmd+"
|
||||
} else {
|
||||
"Super+"
|
||||
};
|
||||
write!(f, "{sup}")?;
|
||||
}
|
||||
if self.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
write!(f, "Ctrl+")?;
|
||||
}
|
||||
if self.modifiers.contains(KeyModifiers::ALT) {
|
||||
let alt = if cfg!(target_os = "macos") {
|
||||
"Opt+"
|
||||
} else {
|
||||
"Alt+"
|
||||
};
|
||||
write!(f, "{alt}")?;
|
||||
}
|
||||
if has_shift {
|
||||
write!(f, "Shift+")?;
|
||||
}
|
||||
match self.code {
|
||||
KeyCode::Char(' ') => write!(f, "Space"),
|
||||
KeyCode::Char(c) => write!(f, "{}", c.to_ascii_lowercase()),
|
||||
KeyCode::Enter => write!(f, "Enter"),
|
||||
KeyCode::Esc => write!(f, "Esc"),
|
||||
KeyCode::Tab => write!(f, "Tab"),
|
||||
KeyCode::BackTab => write!(f, "Shift+Tab"),
|
||||
KeyCode::Backspace => write!(f, "Bsp"),
|
||||
KeyCode::Delete => write!(f, "Del"),
|
||||
KeyCode::Up => write!(f, "↑"),
|
||||
KeyCode::Down => write!(f, "↓"),
|
||||
KeyCode::Left => write!(f, "←"),
|
||||
KeyCode::Right => write!(f, "→"),
|
||||
KeyCode::Home => write!(f, "Home"),
|
||||
KeyCode::End => write!(f, "End"),
|
||||
KeyCode::PageUp => write!(f, "PgUp"),
|
||||
KeyCode::PageDown => write!(f, "PgDn"),
|
||||
KeyCode::F(n) => write!(f, "F{n}"),
|
||||
other => write!(f, "{other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ergonomic macro for constructing [`KeyShortcut`] values.
|
||||
///
|
||||
/// ```ignore
|
||||
/// key!(Enter) // KeyCode::Enter, no modifiers
|
||||
/// key!('q') // KeyCode::Char('q')
|
||||
/// key!('c', CONTROL) // Ctrl-C
|
||||
/// key!('z', CONTROL | SHIFT) // Ctrl+⇧Z
|
||||
/// key!(F(5)) // F5
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! key {
|
||||
// Char literal: key!('c') or key!('c', CONTROL)
|
||||
($char:literal $(, $($mod:ident)|+)? $(,)?) => {
|
||||
$crate::input::key::KeyShortcut::new(
|
||||
::crossterm::event::KeyCode::Char($char),
|
||||
::crossterm::event::KeyModifiers::NONE
|
||||
$($(| ::crossterm::event::KeyModifiers::$mod)+)?,
|
||||
)
|
||||
};
|
||||
// Named key: key!(Enter) or key!(Enter, SHIFT)
|
||||
($code:ident $(, $($mod:ident)|+)? $(,)?) => {
|
||||
$crate::input::key::KeyShortcut::new(
|
||||
::crossterm::event::KeyCode::$code,
|
||||
::crossterm::event::KeyModifiers::NONE
|
||||
$($(| ::crossterm::event::KeyModifiers::$mod)+)?,
|
||||
)
|
||||
};
|
||||
// Function key: key!(F(5))
|
||||
($code:ident ($($arg:tt)*) $(, $($mod:ident)|+)? $(,)?) => {
|
||||
$crate::input::key::KeyShortcut::new(
|
||||
::crossterm::event::KeyCode::$code($($arg)*),
|
||||
::crossterm::event::KeyModifiers::NONE
|
||||
$($(| ::crossterm::event::KeyModifiers::$mod)+)?,
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
pub use key;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn simple_char() {
|
||||
let k = key!('q');
|
||||
assert_eq!(k.code, KeyCode::Char('q'));
|
||||
assert_eq!(k.modifiers, KeyModifiers::NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_modifier() {
|
||||
let k = key!('c', CONTROL);
|
||||
assert_eq!(k.code, KeyCode::Char('c'));
|
||||
assert_eq!(k.modifiers, KeyModifiers::CONTROL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_combined() {
|
||||
let k = key!('z', CONTROL | SHIFT);
|
||||
assert!(k.modifiers.contains(KeyModifiers::CONTROL));
|
||||
assert!(k.modifiers.contains(KeyModifiers::SHIFT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_tab_all_encodings() {
|
||||
use crossterm::event::KeyEvent;
|
||||
// The three encodings terminals use for Shift+Tab.
|
||||
assert!(is_shift_tab(&KeyEvent::new(
|
||||
KeyCode::BackTab,
|
||||
KeyModifiers::NONE
|
||||
)));
|
||||
assert!(is_shift_tab(&KeyEvent::new(
|
||||
KeyCode::BackTab,
|
||||
KeyModifiers::SHIFT
|
||||
)));
|
||||
assert!(is_shift_tab(&KeyEvent::new(
|
||||
KeyCode::Tab,
|
||||
KeyModifiers::SHIFT
|
||||
)));
|
||||
// Plain Tab is NOT Shift+Tab.
|
||||
assert!(!is_shift_tab(&KeyEvent::new(
|
||||
KeyCode::Tab,
|
||||
KeyModifiers::NONE
|
||||
)));
|
||||
// Release events never match.
|
||||
let mut release = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
|
||||
release.kind = KeyEventKind::Release;
|
||||
assert!(!is_shift_tab(&release));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_keys() {
|
||||
assert_eq!(key!(Enter).code, KeyCode::Enter);
|
||||
assert_eq!(key!(Esc).code, KeyCode::Esc);
|
||||
assert_eq!(key!(Tab).code, KeyCode::Tab);
|
||||
assert_eq!(key!(Backspace).code, KeyCode::Backspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_key() {
|
||||
let k = key!(F(5));
|
||||
assert_eq!(k.code, KeyCode::F(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_key_event() {
|
||||
let ctrl_c = key!('c', CONTROL);
|
||||
let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
||||
assert!(ctrl_c.matches(&event));
|
||||
|
||||
let wrong_mod = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
|
||||
assert!(!ctrl_c.matches(&wrong_mod));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_key_event_roundtrip() {
|
||||
let k = key!('x', ALT);
|
||||
let event = k.to_key_event();
|
||||
assert!(k.matches(&event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_paste_key_ctrl_v() {
|
||||
let ev = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
|
||||
assert!(is_paste_key(&ev));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_paste_key_super_v() {
|
||||
let ev = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER);
|
||||
assert!(is_paste_key(&ev));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_paste_key_plain_v_is_not_paste() {
|
||||
let ev = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
|
||||
assert!(!is_paste_key(&ev));
|
||||
}
|
||||
|
||||
/// Alt+V is the Windows-only escape hatch for WT's Ctrl+V interceptor.
|
||||
/// Must NOT match elsewhere (collides with macOS `Opt+V` → `√`).
|
||||
/// Must NOT match AltGr+V on Windows (AltGr = `Ctrl+Alt`, text-input).
|
||||
#[test]
|
||||
fn is_paste_key_alt_v_windows_only() {
|
||||
let alt_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT);
|
||||
assert_eq!(is_paste_key(&alt_v), cfg!(target_os = "windows"));
|
||||
|
||||
let altgr_v = KeyEvent::new(
|
||||
KeyCode::Char('v'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||||
);
|
||||
assert!(!is_paste_key(&altgr_v), "AltGr+V must remain text input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_inline_paste_key_ctrl_shift_v() {
|
||||
let ev = KeyEvent::new(
|
||||
KeyCode::Char('v'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||||
);
|
||||
assert!(is_inline_paste_key(&ev));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_inline_paste_key_super_shift_v() {
|
||||
let ev = KeyEvent::new(
|
||||
KeyCode::Char('v'),
|
||||
KeyModifiers::SUPER | KeyModifiers::SHIFT,
|
||||
);
|
||||
assert!(is_inline_paste_key(&ev));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_formatting() {
|
||||
assert_eq!(key!('q').to_string(), "q");
|
||||
assert_eq!(key!('c', CONTROL).to_string(), "Ctrl+c");
|
||||
assert_eq!(key!(Enter).to_string(), "Enter");
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(key!('x', ALT).to_string(), "Opt+x");
|
||||
} else {
|
||||
assert_eq!(key!('x', ALT).to_string(), "Alt+x");
|
||||
}
|
||||
assert_eq!(key!('n', CONTROL | SHIFT).to_string(), "Ctrl+Shift+n");
|
||||
assert_eq!(key!('h', CONTROL | SHIFT).to_string(), "Ctrl+Shift+h");
|
||||
assert_eq!(key!('g', SHIFT).to_string(), "Shift+g");
|
||||
|
||||
// SUPER is spelled per-platform like Opt/Alt: Cmd on macOS, Super
|
||||
// elsewhere — in both the compact and pretty forms.
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(key!(',', SUPER).to_string(), "Cmd+,");
|
||||
assert_eq!(key!(',', SUPER).display_pretty(), "Cmd+,");
|
||||
} else {
|
||||
assert_eq!(key!(',', SUPER).to_string(), "Super+,");
|
||||
assert_eq!(key!(',', SUPER).display_pretty(), "Super+,");
|
||||
}
|
||||
|
||||
// Pretty (cheatsheet) also spells "Shift" + lowercase letter.
|
||||
assert_eq!(key!('n', CONTROL | SHIFT).display_pretty(), "Ctrl+Shift+n");
|
||||
assert_eq!(key!('h', CONTROL | SHIFT).display_pretty(), "Ctrl+Shift+h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_undo_key_matches_ctrl_and_cmd_z() {
|
||||
assert!(is_undo_key(&KeyEvent::new(
|
||||
KeyCode::Char('z'),
|
||||
KeyModifiers::CONTROL
|
||||
)));
|
||||
assert!(is_undo_key(&KeyEvent::new(
|
||||
KeyCode::Char('z'),
|
||||
KeyModifiers::SUPER
|
||||
)));
|
||||
// Redo (uppercase Z) is never undo.
|
||||
assert!(!is_undo_key(&KeyEvent::new(
|
||||
KeyCode::Char('Z'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_altgr_rejects_single_modifiers() {
|
||||
assert!(!is_altgr(KeyModifiers::NONE));
|
||||
assert!(!is_altgr(KeyModifiers::SHIFT));
|
||||
assert!(!is_altgr(KeyModifiers::CONTROL));
|
||||
assert!(!is_altgr(KeyModifiers::ALT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_altgr_ctrl_alt_platform_dependent() {
|
||||
let mods = KeyModifiers::CONTROL | KeyModifiers::ALT;
|
||||
assert_eq!(is_altgr(mods), cfg!(target_os = "windows"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_input_accepts_plain_and_shifted() {
|
||||
assert!(is_text_input_key(&KeyEvent::new(
|
||||
KeyCode::Char('a'),
|
||||
KeyModifiers::NONE
|
||||
)));
|
||||
assert!(is_text_input_key(&KeyEvent::new(
|
||||
KeyCode::Char('A'),
|
||||
KeyModifiers::SHIFT
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_input_rejects_shortcut_modifiers() {
|
||||
assert!(!is_text_input_key(&KeyEvent::new(
|
||||
KeyCode::Char('c'),
|
||||
KeyModifiers::CONTROL
|
||||
)));
|
||||
assert!(!is_text_input_key(&KeyEvent::new(
|
||||
KeyCode::Char('b'),
|
||||
KeyModifiers::ALT
|
||||
)));
|
||||
assert!(!is_text_input_key(&KeyEvent::new(
|
||||
KeyCode::Char('v'),
|
||||
KeyModifiers::SUPER
|
||||
)));
|
||||
assert!(!is_text_input_key(&KeyEvent::new(
|
||||
KeyCode::Enter,
|
||||
KeyModifiers::NONE
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_input_altgr_platform_dependent() {
|
||||
let at = KeyEvent::new(
|
||||
KeyCode::Char('@'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||||
);
|
||||
assert_eq!(is_text_input_key(&at), cfg!(target_os = "windows"));
|
||||
}
|
||||
}
|
||||
270
crates/codegen/xai-grok-pager/src/input/keyboard_normalizer.rs
Normal file
270
crates/codegen/xai-grok-pager/src/input/keyboard_normalizer.rs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
//! Reconciles delivered key events with physical input state for
|
||||
//! terminal/OS pairs that drop modifier bits the application needs.
|
||||
//! [`KeyboardNormalizer`] pairs an OS-level [`ModifierProbe`] with a
|
||||
//! [`ModifierDelivery`] classification and rewrites incoming
|
||||
//! `KeyEvent`s in place so every downstream surface sees the canonical
|
||||
//! form.
|
||||
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::terminal::ModifierDelivery;
|
||||
|
||||
/// Snapshot of physically-held modifier keys at a single point in time.
|
||||
/// Future probes can populate more bits; consumers should only read what
|
||||
/// they need.
|
||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
|
||||
pub struct ModifierState {
|
||||
pub command: bool,
|
||||
pub option: bool,
|
||||
pub shift: bool,
|
||||
pub control: bool,
|
||||
}
|
||||
|
||||
/// OS-level probe of physical modifier state. One snapshot per call.
|
||||
pub trait ModifierProbe {
|
||||
fn snapshot(&self) -> ModifierState;
|
||||
}
|
||||
|
||||
/// Production probe: macOS reads CoreGraphics, other OSes return all-false.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct OsModifierProbe;
|
||||
|
||||
impl ModifierProbe for OsModifierProbe {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn snapshot(&self) -> ModifierState {
|
||||
super::macos_modifiers::snapshot()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn snapshot(&self) -> ModifierState {
|
||||
ModifierState::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable normalizer that upgrades incoming key events with modifiers
|
||||
/// the terminal failed to encode.
|
||||
///
|
||||
/// One instance lives on [`crate::app::AppView`] and is invoked at the
|
||||
/// top of `handle_input`, so every downstream surface sees the rescued
|
||||
/// event. Construct with [`KeyboardNormalizer::from_terminal_context`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct KeyboardNormalizer<P: ModifierProbe = OsModifierProbe> {
|
||||
probe: P,
|
||||
delivery: ModifierDelivery,
|
||||
}
|
||||
|
||||
impl<P: ModifierProbe> KeyboardNormalizer<P> {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new(probe: P, delivery: ModifierDelivery) -> Self {
|
||||
Self { probe, delivery }
|
||||
}
|
||||
|
||||
/// Upgrade a [`KeyEvent`] when a modifier is held but absent from the
|
||||
/// event. Returns `Some` only if a modifier was added.
|
||||
pub fn rescue_key(&self, key: KeyEvent) -> Option<KeyEvent> {
|
||||
if !self.delivery.benefits_from_rescue() {
|
||||
return None;
|
||||
}
|
||||
if !key.modifiers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !matches!(key.code, KeyCode::Backspace | KeyCode::Delete) {
|
||||
return None;
|
||||
}
|
||||
let state = self.probe.snapshot();
|
||||
// Cmd wins per macOS convention: Cmd+Backspace (line-kill) is the
|
||||
// stronger action; almost no one holds Cmd+Opt simultaneously.
|
||||
let added = match (
|
||||
state.command && self.delivery.cmd.benefits_from_rescue(),
|
||||
state.option && self.delivery.opt.benefits_from_rescue(),
|
||||
) {
|
||||
(true, _) => KeyModifiers::SUPER,
|
||||
(false, true) => KeyModifiers::ALT,
|
||||
_ => return None,
|
||||
};
|
||||
tracing::debug!(
|
||||
key.code = ?key.code,
|
||||
added.modifier = ?added,
|
||||
"key event rescued via OS modifier probe"
|
||||
);
|
||||
let mut out = key;
|
||||
out.modifiers |= added;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Upgrade an [`Event`] in place, owning a fresh `Event::Key` only
|
||||
/// when a rescue actually fires.
|
||||
pub fn rescue<'a>(&self, ev: &'a Event) -> std::borrow::Cow<'a, Event> {
|
||||
if let Event::Key(k) = ev
|
||||
&& let Some(upgraded) = self.rescue_key(*k)
|
||||
{
|
||||
return std::borrow::Cow::Owned(Event::Key(upgraded));
|
||||
}
|
||||
std::borrow::Cow::Borrowed(ev)
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardNormalizer<OsModifierProbe> {
|
||||
pub fn from_terminal_context() -> Self {
|
||||
Self {
|
||||
probe: OsModifierProbe,
|
||||
delivery: crate::terminal::terminal_context()
|
||||
.keyboard_capabilities()
|
||||
.modifier_delivery,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal::ModifierFate;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
struct MockProbe(ModifierState);
|
||||
|
||||
impl ModifierProbe for MockProbe {
|
||||
fn snapshot(&self) -> ModifierState {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
fn drops_both() -> ModifierDelivery {
|
||||
ModifierDelivery::new_for_test(ModifierFate::Dropped, ModifierFate::Dropped)
|
||||
}
|
||||
|
||||
fn make(state: ModifierState, delivery: ModifierDelivery) -> KeyboardNormalizer<MockProbe> {
|
||||
KeyboardNormalizer::new(MockProbe(state), delivery)
|
||||
}
|
||||
|
||||
fn bare(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_backspace_upgrades_to_super() {
|
||||
let n = make(
|
||||
ModifierState {
|
||||
command: true,
|
||||
..Default::default()
|
||||
},
|
||||
drops_both(),
|
||||
);
|
||||
let out = n.rescue_key(bare(KeyCode::Backspace)).unwrap();
|
||||
assert_eq!(out.modifiers, KeyModifiers::SUPER);
|
||||
assert_eq!(out.code, KeyCode::Backspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opt_backspace_upgrades_to_alt() {
|
||||
let n = make(
|
||||
ModifierState {
|
||||
option: true,
|
||||
..Default::default()
|
||||
},
|
||||
drops_both(),
|
||||
);
|
||||
let out = n.rescue_key(bare(KeyCode::Backspace)).unwrap();
|
||||
assert_eq!(out.modifiers, KeyModifiers::ALT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_delete_upgrades_to_super() {
|
||||
let n = make(
|
||||
ModifierState {
|
||||
command: true,
|
||||
..Default::default()
|
||||
},
|
||||
drops_both(),
|
||||
);
|
||||
let out = n.rescue_key(bare(KeyCode::Delete)).unwrap();
|
||||
assert_eq!(out.modifiers, KeyModifiers::SUPER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_takes_precedence_over_opt_when_both_held() {
|
||||
let n = make(
|
||||
ModifierState {
|
||||
command: true,
|
||||
option: true,
|
||||
..Default::default()
|
||||
},
|
||||
drops_both(),
|
||||
);
|
||||
let out = n.rescue_key(bare(KeyCode::Backspace)).unwrap();
|
||||
assert_eq!(out.modifiers, KeyModifiers::SUPER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_modifier_held_skips_rescue() {
|
||||
let n = make(ModifierState::default(), drops_both());
|
||||
assert!(n.rescue_key(bare(KeyCode::Backspace)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_modified_event_skips_rescue() {
|
||||
let n = make(
|
||||
ModifierState {
|
||||
command: true,
|
||||
option: true,
|
||||
..Default::default()
|
||||
},
|
||||
drops_both(),
|
||||
);
|
||||
let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::SHIFT);
|
||||
assert!(n.rescue_key(key).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_deletion_keys_skip_rescue() {
|
||||
let n = make(
|
||||
ModifierState {
|
||||
command: true,
|
||||
option: true,
|
||||
..Default::default()
|
||||
},
|
||||
drops_both(),
|
||||
);
|
||||
for code in [
|
||||
KeyCode::Char('a'),
|
||||
KeyCode::Enter,
|
||||
KeyCode::Esc,
|
||||
KeyCode::Tab,
|
||||
KeyCode::Up,
|
||||
KeyCode::Char('v'),
|
||||
] {
|
||||
assert!(
|
||||
n.rescue_key(bare(code)).is_none(),
|
||||
"rescue should not fire for {code:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rescue_only_adds_modifier_for_dropped_axis() {
|
||||
// Cmd is Native, only Opt is Dropped → bare Backspace + Cmd held
|
||||
// must NOT be rescued (we'd be claiming a modifier the terminal
|
||||
// would have delivered).
|
||||
let opt_only = ModifierDelivery::new_for_test(ModifierFate::Native, ModifierFate::Dropped);
|
||||
let n = make(
|
||||
ModifierState {
|
||||
command: true,
|
||||
..Default::default()
|
||||
},
|
||||
opt_only,
|
||||
);
|
||||
assert!(n.rescue_key(bare(KeyCode::Backspace)).is_none());
|
||||
// But Opt held should still rescue.
|
||||
let n = make(
|
||||
ModifierState {
|
||||
option: true,
|
||||
..Default::default()
|
||||
},
|
||||
opt_only,
|
||||
);
|
||||
assert_eq!(
|
||||
n.rescue_key(bare(KeyCode::Backspace)).unwrap().modifiers,
|
||||
KeyModifiers::ALT
|
||||
);
|
||||
}
|
||||
}
|
||||
50
crates/codegen/xai-grok-pager/src/input/macos_modifiers.rs
Normal file
50
crates/codegen/xai-grok-pager/src/input/macos_modifiers.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//! Native macOS modifier key detection via CoreGraphics.
|
||||
//!
|
||||
//! Side-channels around the PTY directly accessing CoreGraphics.
|
||||
|
||||
// CoreGraphics CGEventSourceFlagsState — returns the current global
|
||||
// modifier flags without requiring any special permissions.
|
||||
#[link(name = "CoreGraphics", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn CGEventSourceFlagsState(stateID: i32) -> u64;
|
||||
}
|
||||
|
||||
const K_CG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE: i32 = 1;
|
||||
|
||||
// CGEventFlags bitmask constants from <CoreGraphics/CGEventTypes.h>
|
||||
const K_CG_EVENT_FLAG_MASK_SHIFT: u64 = 0x0002_0000;
|
||||
const K_CG_EVENT_FLAG_MASK_CONTROL: u64 = 0x0004_0000;
|
||||
const K_CG_EVENT_FLAG_MASK_ALTERNATE: u64 = 0x0008_0000; // Option key
|
||||
const K_CG_EVENT_FLAG_MASK_COMMAND: u64 = 0x0010_0000;
|
||||
|
||||
fn flags() -> u64 {
|
||||
// SAFETY: CGEventSourceFlagsState is a stable, public CoreGraphics API
|
||||
// available since macOS 10.4. Integer in, integer out, no pointers
|
||||
// cross the boundary.
|
||||
unsafe { CGEventSourceFlagsState(K_CG_EVENT_SOURCE_STATE_HID_SYSTEM_STATE) }
|
||||
}
|
||||
|
||||
/// One CG syscall, all modifier bits decoded.
|
||||
pub fn snapshot() -> super::ModifierState {
|
||||
let f = flags();
|
||||
super::ModifierState {
|
||||
command: f & K_CG_EVENT_FLAG_MASK_COMMAND != 0,
|
||||
option: f & K_CG_EVENT_FLAG_MASK_ALTERNATE != 0,
|
||||
shift: f & K_CG_EVENT_FLAG_MASK_SHIFT != 0,
|
||||
control: f & K_CG_EVENT_FLAG_MASK_CONTROL != 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn smoke_test_modifier_detection() {
|
||||
let s = snapshot();
|
||||
let _ = s.command;
|
||||
let _ = s.option;
|
||||
let _ = s.shift;
|
||||
let _ = s.control;
|
||||
}
|
||||
}
|
||||
12
crates/codegen/xai-grok-pager/src/input/mod.rs
Normal file
12
crates/codegen/xai-grok-pager/src/input/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//! Input handling (keys, mouse).
|
||||
|
||||
pub mod key;
|
||||
pub mod keyboard_normalizer;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos_modifiers;
|
||||
pub mod mouse;
|
||||
pub(crate) mod scroll_log;
|
||||
pub mod terminal_support;
|
||||
|
||||
pub use keyboard_normalizer::{KeyboardNormalizer, ModifierState};
|
||||
pub use terminal_support::{is_apple_terminal_newline_modifier_held, is_mod_enter};
|
||||
1442
crates/codegen/xai-grok-pager/src/input/mouse.rs
Normal file
1442
crates/codegen/xai-grok-pager/src/input/mouse.rs
Normal file
File diff suppressed because it is too large
Load diff
1859
crates/codegen/xai-grok-pager/src/input/mouse/tests.rs
Normal file
1859
crates/codegen/xai-grok-pager/src/input/mouse/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
287
crates/codegen/xai-grok-pager/src/input/scroll_log.rs
Normal file
287
crates/codegen/xai-grok-pager/src/input/scroll_log.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
//! Scroll flight recorder — `GROK_SCROLL_LOG` JSONL log of scroll-stream
|
||||
//! transitions, for offline analysis of real gestures.
|
||||
//!
|
||||
//! The scroll-debug HUD ([`crate::views::scroll_debug_hud`]) samples state
|
||||
//! per frame; this recorder captures every state-machine transition
|
||||
//! event-exactly: one line per stream start, per line-delivering flush
|
||||
//! (zero-delta flush attempts are not logged — their spacing shows up in
|
||||
//! `ms_since_prev_flush`), and per finalize. Records are flat JSON objects,
|
||||
//! one per line, and the writer flushes on finalize records so `tail -f` +
|
||||
//! `jq` work mid-session. In captures from before the finalize-decel fix, a
|
||||
//! tick-path finalize with a large `flushed` and nonzero `dropped` long
|
||||
//! after the last `evt="flush"` line is the rear-end burst signature; fixed
|
||||
//! producers drain tapered `trigger="tick"` flushes instead and finalize
|
||||
//! with `flushed: 0`, where `dropped` counts only coast-budget write-offs
|
||||
//! (see [`super::mouse`]).
|
||||
//!
|
||||
//! Enablement: `GROK_SCROLL_LOG=1` (or set-but-empty) logs to
|
||||
//! `~/.grok/logs/scroll-log-<timestamp>.jsonl`; any other non-`0` value is
|
||||
//! used as the target path. Unset (or `0`, matching `GROK_SCROLL_DEBUG`)
|
||||
//! disables: [`super::mouse::MouseScrollState`] then holds `None` and every
|
||||
//! emission point costs one branch.
|
||||
//!
|
||||
//! Invariant (same contract as the HUD): pure observation — the recorder is
|
||||
//! write-only for the state machine and never feeds back into scroll
|
||||
//! behavior. IO failures drop the record and disable the recorder with a
|
||||
//! single `tracing::warn!` (never stderr — that is the TUI's terminal), and
|
||||
//! never panic.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Record type: which state-machine transition produced the line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ScrollLogEvt {
|
||||
/// A new stream was created; its `carry` field shows the sub-line
|
||||
/// remainder that rode in from the previous same-direction stream.
|
||||
StreamStart,
|
||||
/// A flush delivered lines mid-stream.
|
||||
Flush,
|
||||
/// The stream ended (80ms gap or direction flip): `flushed` is the
|
||||
/// tapered catch-up flush (0 once the post-gap drain ran dry), `dropped`
|
||||
/// the whole-line backlog discarded with the stream (flip cancellations
|
||||
/// and coast-budget write-offs).
|
||||
Finalize,
|
||||
}
|
||||
|
||||
/// Code path that emitted the record.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ScrollLogTrigger {
|
||||
/// `on_scroll_event`: stream start, or its 16ms-cadence flush.
|
||||
Event,
|
||||
/// `on_tick` cadence flush (scroll-clock wakeup between events).
|
||||
Tick,
|
||||
/// Immediate flush when Auto-mode wheel promotion fired.
|
||||
Promotion,
|
||||
/// The capped flush inside stream finalize. Detection path is
|
||||
/// recoverable: a finalize followed at the same `ts_ms` by a
|
||||
/// `stream_start` came from the event path (flip/regrasp); one with no
|
||||
/// successor came from the tick path (fingers stopped).
|
||||
Finalize,
|
||||
}
|
||||
|
||||
/// Config echo carried by `stream_start` records only: attributes the
|
||||
/// gesture to a playground variant offline (speed/lines are otherwise
|
||||
/// confounded inside `desired`/`accel`). Per-flush records skip it.
|
||||
/// `ept`/`lpt` abbreviate events/lines per tick; `mode` is the
|
||||
/// [`super::mouse::ScrollInputMode`] label in effect.
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
pub(crate) struct ScrollLogConfigEcho {
|
||||
pub mode: &'static str,
|
||||
pub ept: u16,
|
||||
pub wheel_lpt: u16,
|
||||
pub trackpad_lpt: u16,
|
||||
pub invert: bool,
|
||||
pub speed: f32,
|
||||
pub viewport_height: u16,
|
||||
}
|
||||
|
||||
/// Per-record facts supplied by the state machine ([`super::mouse`]); the
|
||||
/// recorder adds the bookkeeping fields (`ts_ms`, `events_since_flush`,
|
||||
/// `ms_since_prev_flush`) when building the [`ScrollLogRecord`].
|
||||
pub(crate) struct ScrollLogEvent {
|
||||
pub evt: ScrollLogEvt,
|
||||
pub trigger: ScrollLogTrigger,
|
||||
/// Raw stream classification (`unknown` until promotion/finalize).
|
||||
pub kind: &'static str,
|
||||
/// Events accumulated in the stream so far.
|
||||
pub events_total: usize,
|
||||
/// Rolling average inter-event interval (ms); `None` until two
|
||||
/// accel-countable events arrived.
|
||||
pub avg_interval_ms: Option<f32>,
|
||||
/// Acceleration multiplier in effect.
|
||||
pub accel: f32,
|
||||
/// Fractional target lines (post accel/speed multipliers, carry included).
|
||||
pub desired: f32,
|
||||
/// Whole lines delivered for this stream so far (post-flush).
|
||||
pub applied_total: i32,
|
||||
/// Lines this record's flush delivered (0 on stream_start).
|
||||
pub flushed: i32,
|
||||
/// Whole-line backlog remaining after this record's flush.
|
||||
pub backlog_after: i32,
|
||||
/// Sub-line remainder included in `desired`.
|
||||
pub carry: f32,
|
||||
/// Per-flush delta cap in effect.
|
||||
pub cap: i32,
|
||||
/// Finalize only: whole lines discarded with the stream (equals
|
||||
/// `backlog_after` there by construction).
|
||||
pub dropped: Option<i32>,
|
||||
/// Stream-start only: the config captured on the stream.
|
||||
pub config: Option<ScrollLogConfigEcho>,
|
||||
}
|
||||
|
||||
/// One serialized JSONL line: [`ScrollLogEvent`] plus recorder-computed
|
||||
/// `ts_ms` (monotonic ms since recorder start), `events_since_flush`
|
||||
/// (arrivals since the last logged flush/finalize of this stream), and
|
||||
/// `ms_since_prev_flush` (spacing from the previous flush-bearing record;
|
||||
/// absent before the first).
|
||||
#[derive(Serialize)]
|
||||
struct ScrollLogRecord {
|
||||
ts_ms: f64,
|
||||
evt: ScrollLogEvt,
|
||||
trigger: ScrollLogTrigger,
|
||||
kind: &'static str,
|
||||
events_total: usize,
|
||||
events_since_flush: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
avg_interval_ms: Option<f32>,
|
||||
accel: f32,
|
||||
desired: f32,
|
||||
applied_total: i32,
|
||||
flushed: i32,
|
||||
backlog_after: i32,
|
||||
carry: f32,
|
||||
cap: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ms_since_prev_flush: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
dropped: Option<i32>,
|
||||
// Flattened so the config echo stays flat JSON; None emits nothing.
|
||||
#[serde(flatten)]
|
||||
config: Option<ScrollLogConfigEcho>,
|
||||
}
|
||||
|
||||
/// Lazily-opened JSONL sink; `Disabled` after the first IO failure.
|
||||
#[derive(Debug)]
|
||||
enum Sink {
|
||||
Pending(PathBuf),
|
||||
Open(BufWriter<File>),
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/// Appends [`ScrollLogRecord`]s to the `GROK_SCROLL_LOG` file. Owned as
|
||||
/// `Option<Self>` by [`super::mouse::MouseScrollState`]; construction reads
|
||||
/// the env once, the file opens on the first record so an enabled-but-idle
|
||||
/// session creates nothing.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ScrollLogRecorder {
|
||||
/// Time origin for `ts_ms`; the state machine's construction instant
|
||||
/// (tests: the synthetic timeline), or the toggle instant for a
|
||||
/// `/debug log` runtime-enabled recorder — self-consistent either way.
|
||||
base: Instant,
|
||||
sink: Sink,
|
||||
/// Emission time of the previous flush-bearing record.
|
||||
last_flush_at: Option<Instant>,
|
||||
/// `events_total` at the last logged flush/finalize (reset per stream).
|
||||
events_at_last_flush: usize,
|
||||
}
|
||||
|
||||
impl ScrollLogRecorder {
|
||||
/// Build from `GROK_SCROLL_LOG` (see module docs for value semantics);
|
||||
/// `None` when unset or `0`.
|
||||
pub(crate) fn from_env_at(base: Instant) -> Option<Self> {
|
||||
let raw = std::env::var("GROK_SCROLL_LOG").ok()?;
|
||||
let value = raw.trim();
|
||||
if value == "0" {
|
||||
return None;
|
||||
}
|
||||
let path = if value.is_empty() || value == "1" {
|
||||
default_log_path()
|
||||
} else {
|
||||
PathBuf::from(value)
|
||||
};
|
||||
Some(Self::new(path, base))
|
||||
}
|
||||
|
||||
/// Recorder targeting an explicit path (tests inject a tempfile here).
|
||||
pub(crate) fn new(path: PathBuf, base: Instant) -> Self {
|
||||
Self {
|
||||
base,
|
||||
sink: Sink::Pending(path),
|
||||
last_flush_at: None,
|
||||
events_at_last_flush: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one record. `now` must be the same instant the state machine
|
||||
/// used for the transition, so the log is exactly its timeline.
|
||||
pub(crate) fn record(&mut self, now: Instant, event: ScrollLogEvent) {
|
||||
if matches!(self.sink, Sink::Disabled) {
|
||||
return;
|
||||
}
|
||||
let events_since_flush = if event.evt == ScrollLogEvt::StreamStart {
|
||||
self.events_at_last_flush = 0;
|
||||
0
|
||||
} else {
|
||||
event.events_total.saturating_sub(self.events_at_last_flush)
|
||||
};
|
||||
let record = ScrollLogRecord {
|
||||
ts_ms: now.saturating_duration_since(self.base).as_secs_f64() * 1000.0,
|
||||
evt: event.evt,
|
||||
trigger: event.trigger,
|
||||
kind: event.kind,
|
||||
events_total: event.events_total,
|
||||
events_since_flush,
|
||||
avg_interval_ms: event.avg_interval_ms,
|
||||
accel: event.accel,
|
||||
desired: event.desired,
|
||||
applied_total: event.applied_total,
|
||||
flushed: event.flushed,
|
||||
backlog_after: event.backlog_after,
|
||||
carry: event.carry,
|
||||
cap: event.cap,
|
||||
ms_since_prev_flush: self
|
||||
.last_flush_at
|
||||
.map(|at| now.saturating_duration_since(at).as_secs_f64() * 1000.0),
|
||||
dropped: event.dropped,
|
||||
config: event.config,
|
||||
};
|
||||
if event.evt != ScrollLogEvt::StreamStart {
|
||||
self.last_flush_at = Some(now);
|
||||
self.events_at_last_flush = event.events_total;
|
||||
}
|
||||
let Ok(line) = serde_json::to_string(&record) else {
|
||||
return;
|
||||
};
|
||||
self.write_line(&line, event.evt == ScrollLogEvt::Finalize);
|
||||
}
|
||||
|
||||
fn write_line(&mut self, line: &str, flush_now: bool) {
|
||||
if let Sink::Pending(path) = &self.sink {
|
||||
match open_writer(path) {
|
||||
Ok(writer) => self.sink = Sink::Open(writer),
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "scroll log disabled: open failed");
|
||||
self.sink = Sink::Disabled;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Sink::Open(writer) = &mut self.sink else {
|
||||
return;
|
||||
};
|
||||
let result = writeln!(writer, "{line}").and_then(|()| {
|
||||
// Finalize marks a gesture boundary: surface it to tail -f.
|
||||
if flush_now { writer.flush() } else { Ok(()) }
|
||||
});
|
||||
if let Err(err) = result {
|
||||
tracing::warn!(error = %err, "scroll log disabled: write failed");
|
||||
self.sink = Sink::Disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_writer(path: &Path) -> std::io::Result<BufWriter<File>> {
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(BufWriter::new(File::create(path)?))
|
||||
}
|
||||
|
||||
/// `~/.grok/logs/scroll-log-<utc-ts>.jsonl` — the input-debug dump's dir
|
||||
/// and timestamp conventions ([`crate::input_log`]). Also the target of the
|
||||
/// `/debug log` runtime toggle ([`super::mouse::MouseScrollState`]).
|
||||
pub(crate) fn default_log_path() -> PathBuf {
|
||||
let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S");
|
||||
xai_grok_tools::util::grok_home::grok_home()
|
||||
.join("logs")
|
||||
.join(format!("scroll-log-{ts}.jsonl"))
|
||||
}
|
||||
83
crates/codegen/xai-grok-pager/src/input/terminal_support.rs
Normal file
83
crates/codegen/xai-grok-pager/src/input/terminal_support.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
//! OS-level rescue for the modified-Enter chord.
|
||||
//!
|
||||
//! Apple Terminal can't deliver Shift/Opt/Cmd + Enter modifier flags via
|
||||
//! crossterm. We side-channel through the same OS probe used by
|
||||
//! [`super::keyboard_normalizer`] and gate on
|
||||
//! [`crate::terminal::KeyboardCapabilities::enter_needs_rescue`] so the
|
||||
//! per-brand truth lives in one place.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::terminal::terminal_context;
|
||||
|
||||
/// Returns `true` when the user is holding a modifier that should turn a
|
||||
/// bare `Enter` into a newline, and the active terminal is classified as
|
||||
/// dropping that information.
|
||||
pub fn is_apple_terminal_newline_modifier_held() -> bool {
|
||||
let ctx = terminal_context();
|
||||
if !ctx.keyboard_capabilities().enter_needs_rescue() {
|
||||
return false;
|
||||
}
|
||||
os_any_newline_modifier_held()
|
||||
}
|
||||
|
||||
/// Shift/Alt+Enter, or bare Enter while a newline modifier is held and the
|
||||
/// terminal drops those flags ([`is_apple_terminal_newline_modifier_held`]).
|
||||
/// Always requires `KeyCode::Enter` so Shift+Tab / Shift+letters never match.
|
||||
pub fn is_mod_enter(key: &KeyEvent) -> bool {
|
||||
key.code == KeyCode::Enter
|
||||
&& (key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::ALT | KeyModifiers::SHIFT)
|
||||
|| is_apple_terminal_newline_modifier_held())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_any_newline_modifier_held() -> bool {
|
||||
let s = super::macos_modifiers::snapshot();
|
||||
s.shift || s.option || s.command
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn os_any_newline_modifier_held() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
#[test]
|
||||
fn is_mod_enter_requires_enter_code() {
|
||||
assert!(is_mod_enter(&KeyEvent::new(
|
||||
KeyCode::Enter,
|
||||
KeyModifiers::SHIFT
|
||||
)));
|
||||
assert!(is_mod_enter(&KeyEvent::new(
|
||||
KeyCode::Enter,
|
||||
KeyModifiers::ALT
|
||||
)));
|
||||
assert!(!is_mod_enter(&KeyEvent::new(
|
||||
KeyCode::Enter,
|
||||
KeyModifiers::NONE
|
||||
)));
|
||||
// Shift+Tab must never match (BackTab or Tab+SHIFT).
|
||||
assert!(!is_mod_enter(&KeyEvent::new(
|
||||
KeyCode::BackTab,
|
||||
KeyModifiers::NONE
|
||||
)));
|
||||
assert!(!is_mod_enter(&KeyEvent::new(
|
||||
KeyCode::BackTab,
|
||||
KeyModifiers::SHIFT
|
||||
)));
|
||||
assert!(!is_mod_enter(&KeyEvent::new(
|
||||
KeyCode::Tab,
|
||||
KeyModifiers::SHIFT
|
||||
)));
|
||||
assert!(!is_mod_enter(&KeyEvent::new(
|
||||
KeyCode::Char('a'),
|
||||
KeyModifiers::SHIFT
|
||||
)));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue