Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View 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"));
}
}

View 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
);
}
}

View 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;
}
}

View 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};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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"))
}

View 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
)));
}
}