diff --git a/crates/codegen/kigi-pager-render/src/terminal/mod.rs b/crates/codegen/kigi-pager-render/src/terminal/mod.rs index 01fc42b..d3f53ee 100644 --- a/crates/codegen/kigi-pager-render/src/terminal/mod.rs +++ b/crates/codegen/kigi-pager-render/src/terminal/mod.rs @@ -375,7 +375,6 @@ impl TerminalContext { return Some("apple_terminal"); } if self.is_vte_based() { - // WHY: central helper replaces VTE duplication return Some("vte"); } if self.brand == TerminalName::WindowsTerminal { @@ -429,74 +428,24 @@ impl TerminalContext { } /// Whether the running terminal cannot distinguish `Shift+Enter` from - /// bare `Enter` at the byte level, so the UI should advertise - /// `Alt+Enter` for newline insertion instead. + /// bare `Enter`, so the UI should advertise `Alt+Enter` (delivered as + /// `ESC`+`CR`) for newline insertion instead. /// - /// Distinguishing `Shift+Enter` requires the Kitty keyboard protocol - /// (KKP) to be negotiated. This returns `true` for the environments - /// where the pager cannot rely on KKP for a usable `Shift+Enter`: - /// - /// 1. **Legacy VTE** (GNOME Terminal, Ptyxis, kgx, Tilix, etc.) whose - /// `VTE_VERSION` is below `8200` (VTE 0.82.0, the first release with - /// KKP, merged in - /// [MR !14](https://gitlab.gnome.org/GNOME/vte/-/merge_requests/14)). - /// Also true when the brand is detected as VTE but `VTE_VERSION` is - /// missing or unparseable — we conservatively assume old. - /// 2. **VS Code's integrated terminal (xterm.js) and VS Code-family / - /// xterm.js IDE forks**. xterm.js only partially implements KKP — - /// it mis-encodes shifted printable keys — so the pager deliberately - /// never negotiates KKP for them (see [`Self::kitty_skip_reason`] - /// `== "vscode"` and [xterm.js#5823](https://github.com/xtermjs/xterm.js/issues/5823)). - /// Without KKP, xterm.js sends a bare `CR` for `Shift+Enter`, - /// byte-for-byte identical to `Enter`. - /// 3. **Unidentified terminals with no multiplexer**, where the pager - /// also skips KKP (no positive evidence of support — typically - /// VS Code's xterm.js reached over SSH, where `TERM_PROGRAM` isn't - /// forwarded and the brand falls back to `Unknown`). - /// - /// In every case `Alt+Enter` (delivered as `ESC`+`CR`) is the reliable - /// newline chord and is what the UI advertises. + /// Follows [`Self::kitty_skip_reason`] rather than a second brand list: + /// when the protocol is not negotiated the modifier is lost, and the two + /// drifted once already (VTE advertised a chord that never arrived — its + /// `Return` keymap encodes only Alt, through 0.82.0 at least, and no VTE + /// release carries the protocol). Two skips still deliver it — Apple + /// Terminal drops the flags but `route_enter` polls CoreGraphics, and + /// Windows console key records carry SHIFT with no protocol at all. pub fn shift_enter_unavailable(&self) -> bool { - // WHY: central helper + version gating - let is_vte = self.is_vte_based(); - if is_vte { - return match self - .vte_version - .as_deref() - .and_then(|v| v.parse::().ok()) - { - Some(ver) => ver < 8200, - // Brand=Vte but no parseable version — conservative: assume old. - None => true, - }; + match self.kitty_skip_reason() { + None | Some("apple_terminal") => false, + // A bare ConHost is only optimistically refined to Windows + // Terminal; it still advertises Alt+Enter. + Some("windows_terminal") => self.env_brand.is_capability_unclassified(), + Some(_) => true, } - - // VS Code / xterm.js and its forks: KKP is never negotiated, so - // Shift+Enter arrives as a bare CR indistinguishable from Enter. - if matches!( - self.brand, - TerminalName::VsCode - | TerminalName::Cursor - | TerminalName::Windsurf - | TerminalName::Zed - ) { - return true; - } - - // Unidentified / unclassified brand with no multiplexer: KKP is - // skipped (see `kitty_skip_reason`). This is the common - // VS Code-over-SSH shape (brand falls back to Unknown). On native - // Windows the effective `brand` is refined to WindowsTerminal, so - // consult `env_brand` — a bare ConHost is still env-Unknown and - // must advertise Alt+Enter even though we optimistically treat it - // as WT for capabilities. - if self.env_brand.is_capability_unclassified() - && self.multiplexer == MultiplexerKind::Undetected - { - return true; - } - - false } /// True when `Ctrl+.` cannot be delivered reliably as a shortcuts primary. diff --git a/crates/codegen/kigi-pager-render/src/terminal/test.rs b/crates/codegen/kigi-pager-render/src/terminal/test.rs index 8a0cbc8..e483e6e 100644 --- a/crates/codegen/kigi-pager-render/src/terminal/test.rs +++ b/crates/codegen/kigi-pager-render/src/terminal/test.rs @@ -1443,7 +1443,7 @@ fn kitty_skip_vscode_over_tmux() { #[test] fn kitty_skip_vte_version() { - // VTE does not support Kitty keyboard protocol and crossterm's probe + // VTE below 0.82 has no Kitty keyboard protocol and crossterm's probe // can false-positive on it. https://gitlab.gnome.org/GNOME/vte/-/issues/2601 let ctx = TerminalContext { vte_version: Some("7402".to_owned()), @@ -1461,73 +1461,67 @@ fn kitty_skip_vte_brand() { assert_eq!(ctx.kitty_skip_reason(), Some("vte")); } -// shift_enter_unavailable: VTE version gating for Shift+Enter +// shift_enter_unavailable: VTE never delivers Shift+Enter // -// VTE 0.82.0 (= VTE_VERSION 8200) is the first release containing the -// Kitty keyboard protocol; earlier versions cannot distinguish -// Shift+Enter from bare Enter, so the UI should advertise Alt+Enter -// for newline insertion instead. +// No VTE release carries the Kitty keyboard protocol: at 0.82.0 the word +// "kitty" appears nowhere in the parser or widget, and +// `_vte_keymap_GDK_Return` encodes only Alt (`ESC CR`), so Shift+Enter is +// the same bare CR as Enter. The UI advertises Alt+Enter instead. #[test] -fn shift_enter_unavailable_legacy_vte_version() { - // VTE 0.64.2 (real user report) — well below the KKP cutoff. - let ctx = TerminalContext { - vte_version: Some("6402".to_owned()), - ..Default::default() - }; - assert!(ctx.shift_enter_unavailable()); +fn shift_enter_unavailable_on_every_vte() { + for version in [Some("6402"), Some("8200"), Some("8401"), None] { + let ctx = TerminalContext { + brand: TerminalName::Vte, + env_brand: TerminalName::Vte, + vte_version: version.map(str::to_owned), + ..Default::default() + }; + assert_eq!(ctx.kitty_skip_reason(), Some("vte"), "VTE {version:?}"); + assert!(ctx.shift_enter_unavailable(), "VTE {version:?}"); + } } #[test] -fn shift_enter_unavailable_just_below_cutoff() { - // VTE 0.81.99 — anything below 8200 must return true. - let ctx = TerminalContext { - vte_version: Some("8199".to_owned()), - ..Default::default() - }; - assert!(ctx.shift_enter_unavailable()); -} - -#[test] -fn shift_enter_available_modern_vte() { - // VTE 0.82.0 — first release with KKP. - let ctx = TerminalContext { - vte_version: Some("8200".to_owned()), - ..Default::default() - }; - assert!(!ctx.shift_enter_unavailable()); -} - -#[test] -fn shift_enter_available_future_vte() { - // VTE 0.84.1 — well above the cutoff. - let ctx = TerminalContext { - vte_version: Some("8401".to_owned()), - ..Default::default() - }; - assert!(!ctx.shift_enter_unavailable()); -} - -#[test] -fn shift_enter_unavailable_vte_brand_no_version() { - // Brand detected as VTE but VTE_VERSION missing — conservative: old. - let ctx = TerminalContext { - brand: TerminalName::Vte, - vte_version: None, - ..Default::default() - }; - assert!(ctx.shift_enter_unavailable()); -} - -#[test] -fn shift_enter_unavailable_unparseable_version() { - // Garbage in VTE_VERSION — conservative: old. - let ctx = TerminalContext { - brand: TerminalName::Vte, - vte_version: Some("not-a-number".to_owned()), - ..Default::default() - }; - assert!(ctx.shift_enter_unavailable()); +fn shift_enter_unavailable_follows_every_kitty_skip() { + // A skipped protocol means the modifier never arrives, whatever the + // reason — JediTerm, screen and a multiplexer that eats extended keys + // included. Apple Terminal is the one skip with another way in. + for ctx in [ + TerminalContext { + brand: TerminalName::JetBrains, + env_brand: TerminalName::JetBrains, + ..Default::default() + }, + TerminalContext { + brand: TerminalName::Kitty, + env_brand: TerminalName::Kitty, + multiplexer: MultiplexerKind::Screen, + ..Default::default() + }, + TerminalContext { + brand: TerminalName::Kitty, + env_brand: TerminalName::Kitty, + multiplexer: MultiplexerKind::Tmux, + tmux_version: Some("3.2".to_owned()), + ..Default::default() + }, + // A VTE that speaks the protocol still loses it to the multiplexer. + TerminalContext { + brand: TerminalName::Vte, + env_brand: TerminalName::Vte, + vte_version: Some("8200".to_owned()), + multiplexer: MultiplexerKind::Screen, + ..Default::default() + }, + ] { + assert!(ctx.kitty_skip_reason().is_some()); + assert!( + ctx.shift_enter_unavailable(), + "{:?} skips the protocol, so Shift+Enter cannot arrive", + ctx.brand + ); + } } #[test] diff --git a/crates/codegen/kigi-tui/src/slash/commands/terminal_setup.rs b/crates/codegen/kigi-tui/src/slash/commands/terminal_setup.rs index 254fd43..3becef2 100644 --- a/crates/codegen/kigi-tui/src/slash/commands/terminal_setup.rs +++ b/crates/codegen/kigi-tui/src/slash/commands/terminal_setup.rs @@ -96,16 +96,15 @@ impl SlashCommand for TerminalSetupCommand { // Some terminals can't distinguish Shift+Enter from bare Enter at // the byte level because the Kitty keyboard protocol isn't - // negotiated (VTE < 0.82, or VS Code's xterm.js which mis-encodes - // shifted keys). Point users at Alt+Enter, which is reliably - // delivered as ESC+CR. Suppressed when the WezTerm warning fired: - // stock WezTerm binds Alt+Enter to ToggleFullScreen, so advertising - // it would contradict that warning's `\`+Enter guidance. - if ctx.shift_enter_unavailable() && !wezterm_kkp_off { + // negotiated (VTE, VS Code's xterm.js, a multiplexer that eats + // extended keys). Point users at Alt+Enter, which is reliably + // delivered as ESC+CR. Never for WezTerm: it binds Alt+Enter to + // ToggleFullScreen, so its own warning's `\`+Enter guidance stands. + if ctx.shift_enter_unavailable() && !wezterm_kkp_off && ctx.brand != TerminalName::WezTerm { let detail = if ctx.vte_version.is_some() || ctx.brand == TerminalName::Vte { match ctx.vte_version.as_deref() { - Some(v) => format!("VTE {v}; need >= 8200 for Shift+Enter"), - None => "legacy VTE; need VTE >= 0.82 for Shift+Enter".to_owned(), + Some(v) => format!("VTE {v}: no Kitty keyboard protocol"), + None => "VTE: no Kitty keyboard protocol".to_owned(), } } else if matches!( ctx.brand,