Synced from monorepo
Synced from monorepo Changes: - Workspace server: surface preview-proxy metrics through the hub metric pump - Shell: reclaim a session’s retained state in one entry - Shell: reclaim a session’s resident state in one entry - Pager: withhold key event types from Alacritty builds that double keys - Tools: cancel a session’s subagents when it closes - Pager: keep the whole plan in scrollback and separate reasoning from output in minimal mode - Pager: probe terminal version over DA2 and include it with feedback - SuperGrok Plus: identity, CLI, and analytics tier surfaces - Shell: inherit the session process scope into subagents - Pager: build @-file-search matcher lazily on first use - Tools: fix description and output contradictions in tool definitions - Workspace: degrade @-file-search instead of aborting on thread exhaustion - Tools: reap a session’s LSP servers when it closes - Tools: fix contradictions and defects in tool descriptions, schemas, and harness pools - MCP: reap stdio MCP children on session close - Shell: reuse spawn-time skill discovery for session telemetry - Tools: stop leaking shell-wrapper positional params into sourced scripts (fixes activate_conda under persistent/static shell) - Shell: self-heal corrupt session-search SQLite cache - Workspace: cap workspace-server tokio workers on many-core hosts - Shell: reap a session’s child processes when it closes - Crash handler: capture SIGABRT so panic-aborts leave crash reports - CLI chat proxy: team-scoped Grok Code managed-config admin routes - MCP: add CLI enable/disable for MCP servers - Shell: cap tokio worker threads for startup thread demand - Workspace: harden git_commit and add git_sync_base operation - Circuit breaker: add feature-gated gRPC retry policy Source-Revision: 2a818575225183d8ca915f5632a09b8067b5156a
This commit is contained in:
parent
02d9359435
commit
5da6962e4a
192 changed files with 10337 additions and 3421 deletions
|
|
@ -52,6 +52,8 @@ pub struct AppearanceConfig {
|
|||
/// Maximum rows a single committed block may occupy in minimal mode before
|
||||
/// it is truncated with a "… N more lines" footer.
|
||||
pub minimal_max_commit_rows: u16,
|
||||
/// Resolved `[terminal] minimal_collapse_thinking`.
|
||||
pub minimal_collapse_thinking: bool,
|
||||
}
|
||||
|
||||
impl Default for AppearanceConfig {
|
||||
|
|
@ -559,6 +561,16 @@ pub struct ThinkingConfig {
|
|||
/// (matching tool block title style), and respects muted_collapsed when collapsed.
|
||||
/// When false (default), the header is always dim/muted gray.
|
||||
pub header_bright: bool,
|
||||
/// Render the reasoning body de-emphasized (SGR dim + italic) on top of the
|
||||
/// `bg_blend` fade, for surfaces where the fade alone cannot separate
|
||||
/// reasoning from the answer. **Not a TOML key** — minimal mode sets it;
|
||||
/// see the minimal-mode design doc §6.16.
|
||||
pub body_dim_italic: bool,
|
||||
/// Append a dim "(ctrl+e to expand)" affordance to the *collapsed* header
|
||||
/// when it fits on the same row (never adds a row). **Not a TOML key** —
|
||||
/// minimal mode sets it, being the only surface where a folded block cannot
|
||||
/// be unfolded in place.
|
||||
pub collapsed_expand_hint: bool,
|
||||
}
|
||||
|
||||
impl Default for ThinkingConfig {
|
||||
|
|
@ -571,6 +583,8 @@ impl Default for ThinkingConfig {
|
|||
animate: true,
|
||||
header: true,
|
||||
header_bright: false,
|
||||
body_dim_italic: false,
|
||||
collapsed_expand_hint: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -768,6 +782,12 @@ pub struct RawTerminalConfig {
|
|||
pub minimal_live_rows: Option<u16>,
|
||||
/// Maximum rows for a single committed block in minimal mode. Default 2000.
|
||||
pub minimal_max_commit_rows: Option<u16>,
|
||||
/// Commit reasoning ("Thought for Xs") to native scrollback COLLAPSED to
|
||||
/// its one-line header instead of in full. Default false — minimal
|
||||
/// deliberately keeps the whole reasoning body in the transcript (K9); this
|
||||
/// is the opt-out for a terser scrollback. The body stays reachable with
|
||||
/// `Ctrl+E` / `/expand` and `/transcript`.
|
||||
pub minimal_collapse_thinking: bool,
|
||||
}
|
||||
|
||||
impl Default for RawTerminalConfig {
|
||||
|
|
@ -777,6 +797,7 @@ impl Default for RawTerminalConfig {
|
|||
minimal: false,
|
||||
minimal_live_rows: None,
|
||||
minimal_max_commit_rows: None,
|
||||
minimal_collapse_thinking: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1435,6 +1456,7 @@ impl From<RawAppearanceConfig> for AppearanceConfig {
|
|||
minimal: raw.terminal.minimal,
|
||||
minimal_live_rows: raw.terminal.minimal_live_rows.unwrap_or(10),
|
||||
minimal_max_commit_rows: raw.terminal.minimal_max_commit_rows.unwrap_or(2000),
|
||||
minimal_collapse_thinking: raw.terminal.minimal_collapse_thinking,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1603,6 +1625,8 @@ impl From<RawThinkingConfig> for ThinkingConfig {
|
|||
animate: raw.animate,
|
||||
header: raw.header,
|
||||
header_bright: raw.header_bright,
|
||||
body_dim_italic: false,
|
||||
collapsed_expand_hint: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2464,4 +2488,44 @@ gutter_bg = true
|
|||
"Missing alt_screen in generated config:\n{toml}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A config written before the key existed must still parse and keep K9.
|
||||
#[test]
|
||||
fn minimal_collapse_thinking_defaults_off_and_old_configs_parse() {
|
||||
let empty: RawAppearanceConfig = toml::from_str("").expect("empty config must parse");
|
||||
assert!(!empty.terminal.minimal_collapse_thinking);
|
||||
assert!(!AppearanceConfig::from(empty).minimal_collapse_thinking);
|
||||
|
||||
let legacy: RawAppearanceConfig =
|
||||
toml::from_str("[terminal]\nminimal = true\nminimal_live_rows = 12\n")
|
||||
.expect("legacy config must parse");
|
||||
let cfg: AppearanceConfig = legacy.into();
|
||||
assert!(cfg.minimal);
|
||||
assert_eq!(cfg.minimal_live_rows, 12);
|
||||
assert!(
|
||||
!cfg.minimal_collapse_thinking,
|
||||
"a config written before the key existed must keep the K9 default"
|
||||
);
|
||||
|
||||
assert!(!AppearanceConfig::default().minimal_collapse_thinking);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_collapse_thinking_opt_in_parses() {
|
||||
let raw: RawAppearanceConfig =
|
||||
toml::from_str("[terminal]\nminimal_collapse_thinking = true\n").unwrap();
|
||||
assert!(AppearanceConfig::from(raw).minimal_collapse_thinking);
|
||||
}
|
||||
|
||||
/// The reasoning-legibility toggles must stay un-settable from pager.toml.
|
||||
#[test]
|
||||
fn thinking_body_treatment_is_off_by_default_and_not_a_toml_key() {
|
||||
let cfg = AppearanceConfig::default();
|
||||
assert!(!cfg.scrollback.blocks.thinking.body_dim_italic);
|
||||
assert!(!cfg.scrollback.blocks.thinking.collapsed_expand_hint);
|
||||
|
||||
let template = RawAppearanceConfig::to_toml_with_comments();
|
||||
assert!(!template.contains("body_dim_italic"));
|
||||
assert!(!template.contains("collapsed_expand_hint"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ impl GboomState {
|
|||
// On terminals that report key releases (Kitty keyboard protocol),
|
||||
// latch keys on press/release so the player can move and turn at
|
||||
// once; otherwise fall back to the repeat-bridging timer model.
|
||||
// Deliberately `kitty_flags_pushed`, not `kitty_releases_reported`: the
|
||||
// game pushes its own REPORT_ALL_KEYS layer over a downgraded base.
|
||||
game.set_release_aware(crate::terminal::kitty_flags_pushed());
|
||||
Self {
|
||||
game,
|
||||
|
|
|
|||
251
crates/codegen/xai-grok-pager-render/src/terminal/da2.rs
Normal file
251
crates/codegen/xai-grok-pager-render/src/terminal/da2.rs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
//! Runtime DA2 (Secondary Device Attributes) probe: `CSI > 0 c` →
|
||||
//! `CSI > Pp ; Pv ; Pc c`, where `Pv` is a version packed as
|
||||
//! `major * 10000 + minor * 100 + patch`.
|
||||
//!
|
||||
//! Alacritty is the reason this exists: it exports no version environment
|
||||
//! variable and refuses XTVERSION on principle. What it answers with is the
|
||||
//! `alacritty_terminal` library version — see [`unpack_version`].
|
||||
//!
|
||||
//! Unlike [`super::xtversion`] the reply is read at the fd rather than
|
||||
//! recognized by an event-loop filter, because no filter could see it:
|
||||
//! crossterm has no `CSI >` arm, so it errors and clears its buffer, dropping
|
||||
//! the intro and leaving digits indistinguishable from typing.
|
||||
//!
|
||||
//! The read owns stdin, so it must run after `enable_raw_mode()` and before
|
||||
//! crossterm's `EventStream` exists. A late reply that arrives partially is
|
||||
//! drained to quiet by [`super::probe`]; one of which *no* byte arrives before
|
||||
//! the deadline is left for crossterm, which types it into the composer —
|
||||
//! `REPLY_TIMEOUT` is sized to keep that out of reach.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
#[cfg(unix)]
|
||||
use std::time::Duration;
|
||||
|
||||
static DA2_VERSION: OnceLock<Option<Da2Version>> = OnceLock::new();
|
||||
|
||||
/// Both forms of one reply. The packed integer is kept rather than recovered
|
||||
/// from `text`, so version gates compare what the terminal sent instead of
|
||||
/// re-parsing what this module formatted.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
struct Da2Version {
|
||||
packed: u32,
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
const QUERY: &[u8] = b"\x1b[>0c";
|
||||
|
||||
/// Sized for a slow link, not for a silent terminal: a reply that misses the
|
||||
/// deadline entirely is typed into the composer, not merely lost.
|
||||
#[cfg(unix)]
|
||||
const REPLY_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Rejects a packed value that cannot be a real release (major ≥ 100) instead
|
||||
/// of folding it into a plausible-looking version.
|
||||
#[cfg(any(unix, test))]
|
||||
const MAX_PACKED_VERSION: u32 = 999_999;
|
||||
|
||||
/// Returns the version the terminal reported over DA2, if it answered.
|
||||
pub fn detected() -> Option<&'static str> {
|
||||
Some(DA2_VERSION.get()?.as_ref()?.text.as_str())
|
||||
}
|
||||
|
||||
/// [`detected`]'s reply as the packed integer the terminal sent
|
||||
/// (`major * 10000 + minor * 100 + patch`).
|
||||
pub fn detected_packed() -> Option<u32> {
|
||||
Some(DA2_VERSION.get()?.as_ref()?.packed)
|
||||
}
|
||||
|
||||
/// Query DA2 once at startup and read the reply under a bounded deadline;
|
||||
/// no-ops when the gate rejects the brand/multiplexer or stdin is not a TTY.
|
||||
pub fn probe_at_startup() {
|
||||
use std::io::IsTerminal;
|
||||
|
||||
if DA2_VERSION.get().is_some() {
|
||||
return;
|
||||
}
|
||||
let ctx = super::terminal_context();
|
||||
if !gate_allows_probe(ctx) || !std::io::stdin().is_terminal() {
|
||||
let _ = DA2_VERSION.set(None);
|
||||
return;
|
||||
}
|
||||
query_and_read();
|
||||
}
|
||||
|
||||
/// Deliberately narrow: Alacritty is the only brand whose version is otherwise
|
||||
/// unreachable, and it is excluded from [`super::xtversion`]'s allowlist, which
|
||||
/// the synchronous read depends on. CSI-intercepting multiplexers skip — tmux
|
||||
/// answers DA2 as itself, and passthrough still returns the reply through it.
|
||||
fn gate_allows_probe(ctx: &super::TerminalContext) -> bool {
|
||||
ctx.brand == super::TerminalName::Alacritty && !ctx.multiplexer.intercepts_csi_queries()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn query_and_read() {
|
||||
if !super::probe::write_query(QUERY) {
|
||||
tracing::debug!("DA2 probe skipped: query write failed or output is not a TTY");
|
||||
let _ = DA2_VERSION.set(None);
|
||||
return;
|
||||
}
|
||||
// Only the DA2 intro ends the read: startup typeahead can already hold a
|
||||
// `>` and a `c` (`ls > out.c`), and a late DA1 reply has the escape but a
|
||||
// `?`. Both are consumed instead, and the read continues to the reply.
|
||||
let reply = super::probe::read_tty_reply(REPLY_TIMEOUT, |buf, byte| {
|
||||
byte == b'c' && buf.windows(3).any(|w| w == b"\x1b[>")
|
||||
});
|
||||
let version = reply.as_deref().and_then(parse_version);
|
||||
if let Some(bytes) = reply.as_deref()
|
||||
&& version.is_none()
|
||||
{
|
||||
// A bare `None` cannot distinguish a rejected reply from silence.
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
tracing::debug!(reply = %text.escape_debug(), "DA2 reply rejected");
|
||||
}
|
||||
tracing::info!(version = ?version.as_ref().map(|v| &v.text), "DA2 probe");
|
||||
let _ = DA2_VERSION.set(version);
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn query_and_read() {
|
||||
// The timed read is Unix-only, and ConPTY does not answer DA2.
|
||||
let _ = DA2_VERSION.set(None);
|
||||
}
|
||||
|
||||
/// Decode `CSI > Pp ; Pv ; Pc c`, rejecting anything that is not Alacritty's
|
||||
/// exact reply shape.
|
||||
///
|
||||
/// `Pv` means whatever its emulator decided — xterm puts a patch level there,
|
||||
/// so `> 0 ; 388 ; 0 c` would decode to a confident, wrong `0.3.88`. The brand
|
||||
/// evidence here is only `TERM=alacritty`, so the shape upstream hardcodes
|
||||
/// (`Pp == 0`, `Pc == 1`) is what makes the number trustworthy.
|
||||
#[cfg(any(unix, test))]
|
||||
fn parse_version(reply: &[u8]) -> Option<Da2Version> {
|
||||
let text = String::from_utf8_lossy(reply);
|
||||
// Split at the last `>` so a keystroke racing the reply cannot shift the
|
||||
// parameter list.
|
||||
let (_, params) = text.rsplit_once('>')?;
|
||||
let mut fields = params.trim_end().trim_end_matches('c').split(';');
|
||||
if fields.next()?.trim() != "0" {
|
||||
return None;
|
||||
}
|
||||
let packed: u32 = fields.next()?.trim().parse().ok()?;
|
||||
if fields.next()?.trim() != "1" {
|
||||
return None;
|
||||
}
|
||||
unpack_version(packed)
|
||||
}
|
||||
|
||||
/// For Alacritty the decoded value is the `alacritty_terminal` **library**
|
||||
/// version, not the application release: upstream packs the library crate's own
|
||||
/// `CARGO_PKG_VERSION`, and the two diverged after 0.5 — release 0.15.1 answers
|
||||
/// `2500`. Reported as-is. Pre-release suffixes are stripped upstream, so a
|
||||
/// `-dev` build is indistinguishable from the matching release.
|
||||
#[cfg(any(unix, test))]
|
||||
fn unpack_version(packed: u32) -> Option<Da2Version> {
|
||||
if packed == 0 || packed > MAX_PACKED_VERSION {
|
||||
return None;
|
||||
}
|
||||
let major = packed / 10_000;
|
||||
let minor = (packed / 100) % 100;
|
||||
let patch = packed % 100;
|
||||
Some(Da2Version {
|
||||
packed,
|
||||
text: format!("{major}.{minor}.{patch}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal::{MultiplexerKind, TerminalContext, TerminalName};
|
||||
|
||||
fn parsed(reply: &[u8]) -> Option<(u32, String)> {
|
||||
parse_version(reply).map(|v| (v.packed, v.text))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_version_round_trips() {
|
||||
// Real `alacritty_terminal` versions, not the releases they ship in:
|
||||
// 0.21 is Alacritty 0.13.x; 0.25 is 0.15.1+ (0.15.0 still shipped 0.24.2).
|
||||
assert_eq!(
|
||||
parsed(b"\x1b[>0;2100;1c"),
|
||||
Some((2100, "0.21.0".to_owned()))
|
||||
);
|
||||
assert_eq!(
|
||||
parsed(b"\x1b[>0;2500;1c"),
|
||||
Some((2500, "0.25.0".to_owned()))
|
||||
);
|
||||
assert_eq!(
|
||||
parsed(b"\x1b[>0;2601;1c"),
|
||||
Some((2601, "0.26.1".to_owned()))
|
||||
);
|
||||
// Typeahead consumed ahead of the reply: the last `>` is still the
|
||||
// reply's, so its parameters are what get parsed.
|
||||
assert_eq!(
|
||||
parsed(b"ls > out.c\x1b[>0;2500;1c"),
|
||||
Some((2500, "0.25.0".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_emulators_da2_is_not_a_version() {
|
||||
// xterm's `Pv` is a patch level and VTE's is its own numbering; both
|
||||
// would otherwise decode cleanly.
|
||||
assert_eq!(parse_version(b"\x1b[>41;389;0c"), None);
|
||||
assert_eq!(parse_version(b"\x1b[>0;388;0c"), None);
|
||||
assert_eq!(parse_version(b"\x1b[>65;6003;1c"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecodable_payloads_are_none() {
|
||||
// Absent, empty, or truncated parameter lists.
|
||||
assert_eq!(parse_version(b""), None);
|
||||
assert_eq!(parse_version(b"c"), None);
|
||||
assert_eq!(parse_version(b"\x1b[>0c"), None);
|
||||
assert_eq!(parse_version(b"\x1b[>0;;1c"), None);
|
||||
assert_eq!(parse_version(b"\x1b[?62;1;6c"), None);
|
||||
// Non-numeric, signed, and absurd values must not wrap or panic.
|
||||
assert_eq!(parse_version(b"\x1b[>0;abc;1c"), None);
|
||||
assert_eq!(parse_version(b"\x1b[>0;-1;1c"), None);
|
||||
assert_eq!(parse_version(b"\x1b[>0;0;1c"), None);
|
||||
assert_eq!(parse_version(b"\x1b[>0;4294967295;1c"), None);
|
||||
assert_eq!(parse_version(b"\x1b[>0;99999999999999999999;1c"), None);
|
||||
assert_eq!(parse_version(b"\x1b[>0;1000000;1c"), None);
|
||||
assert_eq!(parse_version(b"\x1b[>0;\xff\xfe;1c"), None);
|
||||
}
|
||||
|
||||
/// The DA2 read would eat an XTVERSION reply in flight for the same brand.
|
||||
/// Widening this gate onto a brand XTVERSION already probes is the edit
|
||||
/// that would break it, so the complement is what gets asserted.
|
||||
#[test]
|
||||
fn no_brand_is_probed_by_both_xtversion_and_da2() {
|
||||
use crate::terminal::xtversion;
|
||||
|
||||
let ctx = |brand| TerminalContext {
|
||||
brand,
|
||||
multiplexer: MultiplexerKind::Undetected,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(gate_allows_probe(&ctx(TerminalName::Alacritty)));
|
||||
assert!(!xtversion::gate_allows_probe(&ctx(TerminalName::Alacritty)));
|
||||
|
||||
for brand in [
|
||||
TerminalName::Unknown,
|
||||
TerminalName::Kitty,
|
||||
TerminalName::WezTerm,
|
||||
TerminalName::Ghostty,
|
||||
TerminalName::Iterm2,
|
||||
TerminalName::Rio,
|
||||
] {
|
||||
// Keeps this hardcoded copy of the allowlist from going vacuous.
|
||||
assert!(
|
||||
xtversion::gate_allows_probe(&ctx(brand)),
|
||||
"{brand:?} left the XTVERSION allowlist this asserts against"
|
||||
);
|
||||
assert!(
|
||||
!gate_allows_probe(&ctx(brand)),
|
||||
"{brand:?} would be probed by both"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
//! Which Kitty keyboard enhancement flags the pager negotiates at startup, and
|
||||
//! the process-global record of the set it actually pushed.
|
||||
//!
|
||||
//! "Flags pushed" is not "releases arrive", and conflating them is the bug this
|
||||
//! module exists to prevent: Alacritty ≤ 0.14.x is pushed
|
||||
//! `DISAMBIGUATE_ESCAPE_CODES` without `REPORT_EVENT_TYPES`, so the protocol is
|
||||
//! live — `Shift+Enter` works, teardown owes a pop — yet no release ever comes,
|
||||
//! and a hold-to-talk started there could only end on Esc.
|
||||
//!
|
||||
//! The gate reads the reported version rather than watching behaviour because
|
||||
//! there is nothing to watch: a conforming terminal reports no release for these
|
||||
//! keys either (kitty spec), so healthy and broken differ by one byte per
|
||||
//! keystroke, gone by the time events are decoded. An earlier design died on it.
|
||||
//!
|
||||
//! Deliberately uncovered: [`super::da2`] is skipped under CSI-intercepting
|
||||
//! multiplexers but [`super::TerminalContext::kitty_skip_reason`] is not, so an
|
||||
//! affected Alacritty inside tmux ≥ 3.3 answers nothing, keeps
|
||||
//! `REPORT_EVENT_TYPES` and still double-submits Enter. Downgrading everything
|
||||
//! that answers nothing would cost far more healthy sessions than that slice.
|
||||
//!
|
||||
//! What losing `REPORT_EVENT_TYPES` costs an affected session:
|
||||
//!
|
||||
//! - Voice hold-to-talk degrades to a tap toggle (`voice_chord_action`; the
|
||||
//! `voice_capture_mode` setting hides its `hold` choice).
|
||||
//! - `is_link_modifier_for_key`'s non-macOS Ctrl-release case (`xai-grok-pager`
|
||||
//! `src/app/agent_view/mod.rs`) never fires, so link-hover clears on the next
|
||||
//! non-Ctrl key instead of when Ctrl lifts.
|
||||
//! - `KeyEventKind::Repeat` disappears: held keys arrive as repeated `Press`, as
|
||||
//! on every non-KKP terminal — but `is_pasteable_key_event` (`xai-grok-pager`
|
||||
//! `src/app/event_loop.rs`) excludes `Repeat` on purpose, so auto-repeat
|
||||
//! counts toward paste coalescing again.
|
||||
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
use crossterm::event::KeyboardEnhancementFlags;
|
||||
|
||||
/// Highest packed `alacritty_terminal` version that mis-encodes
|
||||
/// `REPORT_EVENT_TYPES`: the *release* of Backspace, Tab, Enter and Escape comes
|
||||
/// back as a duplicate legacy byte, which carries no event type, so crossterm
|
||||
/// reads it as a second `Press` and one keypress acts twice — Enter submits
|
||||
/// twice. (Upstream's CHANGELOG omits Escape; its `key_release` arm does not.)
|
||||
///
|
||||
/// DA2 reports the **library** version, not the Alacritty release: 0.14.0 ships
|
||||
/// 0.24.1 → `2401`, 0.15.0 ships 0.24.2 → `2402`. Fixed by `7bda13b8aa`
|
||||
/// (2025-01-04); CHANGELOG **v0.15.0 → Fixed**: *"Report of Enter/Tab/Backspace
|
||||
/// in kitty keyboard's report event types mode."* There is no `v0.14.1`, so
|
||||
/// 0.14.0 is the whole affected *release* population — this threshold never
|
||||
/// moves, it only gets retired.
|
||||
///
|
||||
/// Git builds escape it: master carried `0.24.2-dev` from 2024-10-18 to
|
||||
/// 2025-01-09 and the suffix is stripped before packing, so a pre-fix build from
|
||||
/// that window reports `2402`.
|
||||
pub const ALACRITTY_BROKEN_EVENT_TYPES_MAX_PACKED: u32 = 2401;
|
||||
|
||||
/// The flags to push at startup; empty means push nothing.
|
||||
///
|
||||
/// An unknown version never downgrades: DA2 is skipped under multiplexers and
|
||||
/// off unix, so `None` is common and the downgrade costs the features listed in
|
||||
/// the module docs. Only a positively identified affected version gets it.
|
||||
///
|
||||
/// The missing brand check is load-bearing: [`super::da2`]'s probe gate admits
|
||||
/// Alacritty alone, so a version in hand already implies the brand, and widening
|
||||
/// that gate silently widens this one.
|
||||
pub fn negotiated_kitty_flags(
|
||||
skip_reason: Option<&str>,
|
||||
da2_packed: Option<u32>,
|
||||
) -> KeyboardEnhancementFlags {
|
||||
if skip_reason.is_some() {
|
||||
return KeyboardEnhancementFlags::empty();
|
||||
}
|
||||
let mut flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES;
|
||||
let mis_encodes_releases =
|
||||
da2_packed.is_some_and(|packed| packed <= ALACRITTY_BROKEN_EVENT_TYPES_MAX_PACKED);
|
||||
if !mis_encodes_releases {
|
||||
flags |= KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
/// Bits of the [`KeyboardEnhancementFlags`] `init_terminal` pushed; `0` is
|
||||
/// `empty()`. Storing the set actually sent, rather than a classification of it,
|
||||
/// is what keeps the predicates below from drifting apart.
|
||||
///
|
||||
/// `Relaxed`: the cell publishes no other memory, and readers are already
|
||||
/// ordered after `init_terminal` by the task creation between them.
|
||||
static PUSHED_KITTY_FLAGS: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
fn pushed_kitty_flags() -> KeyboardEnhancementFlags {
|
||||
KeyboardEnhancementFlags::from_bits_truncate(PUSHED_KITTY_FLAGS.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
pub fn set_pushed_kitty_flags(flags: KeyboardEnhancementFlags) {
|
||||
PUSHED_KITTY_FLAGS.store(flags.bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether Kitty keyboard enhancement flags were actually pushed during
|
||||
/// `init_terminal` — i.e. the brand wasn't in the skip list *and* the
|
||||
/// runtime probe (`supports_keyboard_enhancement`) succeeded. False means
|
||||
/// modified keys (Shift+Enter, Ctrl+.) arrive as legacy bytes.
|
||||
///
|
||||
/// This is **not** "key releases arrive" — use [`kitty_releases_reported`].
|
||||
pub fn kitty_flags_pushed() -> bool {
|
||||
!pushed_kitty_flags().is_empty()
|
||||
}
|
||||
|
||||
/// Whether the terminal reports key *release* events. Every feature that waits
|
||||
/// for a release (hold-to-talk, modifier-lift tracking) must gate on this, not
|
||||
/// on [`kitty_flags_pushed`]: the two differ on Alacritty ≤ 0.14.x.
|
||||
pub fn kitty_releases_reported() -> bool {
|
||||
pushed_kitty_flags().contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
|
||||
}
|
||||
|
||||
/// Whether the version workaround engaged: pushed, but without
|
||||
/// `REPORT_EVENT_TYPES`. Not `!kitty_releases_reported()`, which is also true
|
||||
/// when nothing was pushed at all.
|
||||
pub fn kitty_event_types_withheld() -> bool {
|
||||
let flags = pushed_kitty_flags();
|
||||
!flags.is_empty() && !flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
|
||||
}
|
||||
|
||||
/// Clears the record as it reads, so concurrent teardown paths cannot both pop.
|
||||
pub fn take_kitty_flags_pushed() -> bool {
|
||||
PUSHED_KITTY_FLAGS.swap(0, Ordering::Relaxed) != 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DISAMBIGUATE: KeyboardEnhancementFlags =
|
||||
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES;
|
||||
const EVENT_TYPES: KeyboardEnhancementFlags = KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
|
||||
|
||||
/// The exact boundary a careless `<`/`<=` edit breaks: `alacritty_terminal`
|
||||
/// 0.24.1 is Alacritty 0.14.0 (broken), 0.24.2 is 0.15.0 (fixed).
|
||||
#[test]
|
||||
fn downgrade_boundary_is_the_last_broken_library_version() {
|
||||
// Non-empty either side: a downgrade is still a push, teardown owes a pop.
|
||||
assert_eq!(negotiated_kitty_flags(None, Some(2401)), DISAMBIGUATE);
|
||||
assert_eq!(
|
||||
negotiated_kitty_flags(None, Some(2402)),
|
||||
DISAMBIGUATE | EVENT_TYPES
|
||||
);
|
||||
}
|
||||
|
||||
/// DA2 is skipped under multiplexers and off unix, so "no answer" is the
|
||||
/// common case and must not cost a healthy terminal its release events.
|
||||
#[test]
|
||||
fn absent_version_does_not_downgrade() {
|
||||
assert_eq!(
|
||||
negotiated_kitty_flags(None, None),
|
||||
DISAMBIGUATE | EVENT_TYPES
|
||||
);
|
||||
}
|
||||
|
||||
/// A skip reason outranks any version, so teardown owes no pop.
|
||||
#[test]
|
||||
fn a_skip_reason_pushes_nothing() {
|
||||
for packed in [None, Some(2401), Some(2402)] {
|
||||
assert_eq!(
|
||||
negotiated_kitty_flags(Some("vscode"), packed),
|
||||
KeyboardEnhancementFlags::empty(),
|
||||
"da2_packed={packed:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,14 +5,15 @@
|
|||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use crate::host::HostOs;
|
||||
|
||||
pub mod da2;
|
||||
pub mod embedded_editor;
|
||||
pub mod hyperlinks;
|
||||
pub mod image;
|
||||
pub mod keyboard;
|
||||
pub mod kitty_keyboard;
|
||||
pub mod overlay;
|
||||
pub(crate) mod probe;
|
||||
pub mod term_version;
|
||||
|
|
@ -28,6 +29,10 @@ pub use keyboard::{
|
|||
KeyboardCapabilities, ModifierDelivery, ModifierFate, keyboard_capabilities,
|
||||
keyboard_capabilities_for_host,
|
||||
};
|
||||
pub use kitty_keyboard::{
|
||||
kitty_event_types_withheld, kitty_flags_pushed, kitty_releases_reported,
|
||||
negotiated_kitty_flags, set_pushed_kitty_flags, take_kitty_flags_pushed,
|
||||
};
|
||||
pub use term_version::{TermVersion, TermVersionSource};
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -43,32 +48,6 @@ pub(crate) fn env_from(pairs: &[(&str, &str)]) -> HashMap<String, String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
// TODO: make term seq codes invariant in a crate.
|
||||
/// Tracks whether Kitty keyboard enhancement flags were pushed during
|
||||
/// `init_terminal`, so teardown paths (`restore_terminal`, panic hook)
|
||||
/// only pop when flags were actually pushed.
|
||||
static KITTY_FLAGS_PUSHED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Whether Kitty keyboard enhancement flags were actually pushed during
|
||||
/// `init_terminal` — i.e. the brand wasn't in the skip list *and* the
|
||||
/// runtime probe (`supports_keyboard_enhancement`) succeeded. False means
|
||||
/// modified keys (Shift+Enter, Ctrl+.) arrive as legacy bytes.
|
||||
pub fn kitty_flags_pushed() -> bool {
|
||||
KITTY_FLAGS_PUSHED.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Record whether Kitty keyboard enhancement flags were pushed during
|
||||
/// `init_terminal`.
|
||||
pub fn set_kitty_flags_pushed(v: bool) {
|
||||
KITTY_FLAGS_PUSHED.store(v, Ordering::Release)
|
||||
}
|
||||
|
||||
/// Atomically clear the Kitty-flags-pushed state, returning the prior value.
|
||||
/// Used by teardown paths so concurrent callers cannot both pop.
|
||||
pub fn take_kitty_flags_pushed() -> bool {
|
||||
KITTY_FLAGS_PUSHED.swap(false, Ordering::AcqRel)
|
||||
}
|
||||
|
||||
/// Known terminal emulator categories.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, strum::Display)]
|
||||
pub enum TerminalName {
|
||||
|
|
@ -573,14 +552,10 @@ impl TerminalContext {
|
|||
|
||||
/// The best available terminal version and the source that reported it.
|
||||
///
|
||||
/// Only the environment arm exists today. Final precedence is
|
||||
/// `da2 > xtversion > env` — probe arms insert **above** it, since a live
|
||||
/// self-report cannot be inherited or go stale.
|
||||
/// Not pure: the DA2 arm reads process-global probe state, so env-precedence
|
||||
/// tests hold only while no reply has been recorded in the process.
|
||||
pub fn term_version(&self) -> (String, TermVersionSource) {
|
||||
match &self.env_term_version {
|
||||
Some(v) => (v.version.clone(), v.source),
|
||||
None => (String::new(), TermVersionSource::None),
|
||||
}
|
||||
term_version::best_term_version(da2::detected(), self.env_term_version.as_ref())
|
||||
}
|
||||
|
||||
/// Extract a flat snapshot of terminal details for telemetry.
|
||||
|
|
@ -605,6 +580,7 @@ impl TerminalContext {
|
|||
xtversion: xtversion::detected().unwrap_or("").to_owned(),
|
||||
term_version,
|
||||
term_version_source: term_version_source.to_string(),
|
||||
kitty_event_types_withheld: kitty_event_types_withheld(),
|
||||
hyperlink_osc8: self.hyperlink_capabilities().osc8.to_string(),
|
||||
hyperlink_skip_reason: self.hyperlink_skip_reason().unwrap_or("none").to_owned(),
|
||||
clipboard_route: route.to_string(),
|
||||
|
|
@ -622,12 +598,16 @@ impl TerminalContext {
|
|||
Some(v) if self.brand == TerminalName::Unknown => format!("Unknown (XTVERSION: {v})"),
|
||||
_ => self.brand.to_string(),
|
||||
};
|
||||
// Raw and unlabeled by design: no provenance, and no rewrite of DA2's
|
||||
// library version into an Alacritty release number.
|
||||
let (term_version, _source) = self.term_version();
|
||||
FeedbackTerminalInfo {
|
||||
brand,
|
||||
multiplexer: self.multiplexer.to_string(),
|
||||
is_ssh: self.is_ssh,
|
||||
is_byobu: self.is_byobu(),
|
||||
term_var: self.term_var_or_na().to_owned(),
|
||||
term_version: (!term_version.is_empty()).then_some(term_version),
|
||||
tmux_version: if self.is_tmux_backed() {
|
||||
self.tmux_version.clone()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -15,13 +15,15 @@ use std::collections::HashMap;
|
|||
|
||||
use super::{TerminalName, terminal_name_from_term_program};
|
||||
|
||||
/// Which environment variable produced a [`TermVersion`].
|
||||
/// Which source produced a [`TermVersion`].
|
||||
///
|
||||
/// The rendered labels are stable telemetry values — do not rename them.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, strum::Display)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum TermVersionSource {
|
||||
None,
|
||||
/// The runtime [`crate::terminal::da2`] probe — the only non-env source.
|
||||
Da2,
|
||||
/// `TERM_PROGRAM_VERSION`, or its SSH-surviving `LC_TERMINAL_VERSION`
|
||||
/// mirror (iTerm2 only).
|
||||
TermProgram,
|
||||
|
|
@ -64,6 +66,19 @@ fn corroborates(named: TerminalName, env_brand: TerminalName) -> bool {
|
|||
))
|
||||
}
|
||||
|
||||
/// Pick the best available version: a runtime probe outranks the environment,
|
||||
/// since a live self-report cannot be inherited across a process, SSH or
|
||||
/// multiplexer boundary, nor go stale. XTVERSION has no arm — its payload is a
|
||||
/// name-and-version string, and it rides `TerminalTelemetry::xtversion`.
|
||||
pub(super) fn best_term_version(
|
||||
da2: Option<&str>,
|
||||
env_version: Option<&TermVersion>,
|
||||
) -> (String, TermVersionSource) {
|
||||
da2.map(|version| (version.to_owned(), TermVersionSource::Da2))
|
||||
.or_else(|| env_version.map(|v| (v.version.clone(), v.source)))
|
||||
.unwrap_or_else(|| (String::new(), TermVersionSource::None))
|
||||
}
|
||||
|
||||
/// Look up an env value, trimmed; `env_get` alone would pass whitespace.
|
||||
fn env_trimmed<'a>(env: &'a HashMap<String, String>, key: &str) -> Option<&'a str> {
|
||||
let value = super::env_get(env, key)?.trim();
|
||||
|
|
@ -136,11 +151,45 @@ mod tests {
|
|||
#[test]
|
||||
fn source_labels_are_pinned() {
|
||||
assert_eq!(TermVersionSource::None.to_string(), "none");
|
||||
assert_eq!(TermVersionSource::Da2.to_string(), "da2");
|
||||
assert_eq!(TermVersionSource::TermProgram.to_string(), "term_program");
|
||||
assert_eq!(TermVersionSource::WezTerm.to_string(), "wezterm");
|
||||
assert_eq!(TermVersionSource::Vte.to_string(), "vte");
|
||||
}
|
||||
|
||||
/// Driven through `best_term_version` rather than the probe's process-global
|
||||
/// `OnceLock`: this crate's tests share one process, so recording a reply
|
||||
/// would race every env-precedence assertion below.
|
||||
#[test]
|
||||
fn a_probed_version_outranks_env() {
|
||||
let env = TermVersion::new("7402", TermVersionSource::Vte);
|
||||
assert_eq!(
|
||||
best_term_version(Some("0.25.0"), Some(&env)),
|
||||
("0.25.0".to_owned(), TermVersionSource::Da2)
|
||||
);
|
||||
assert_eq!(
|
||||
best_term_version(None, Some(&env)),
|
||||
("7402".to_owned(), TermVersionSource::Vte)
|
||||
);
|
||||
assert_eq!(
|
||||
best_term_version(None, None),
|
||||
(String::new(), TermVersionSource::None)
|
||||
);
|
||||
}
|
||||
|
||||
/// The version has to reach the feedback card; its source has no field on
|
||||
/// the wire type to reach it through.
|
||||
#[test]
|
||||
fn feedback_info_carries_the_version() {
|
||||
let present =
|
||||
build_terminal_context_from_env(&env_from(&[("VTE_VERSION", "7402")])).feedback_info();
|
||||
assert_eq!(present.term_version.as_deref(), Some("7402"));
|
||||
|
||||
let absent = build_terminal_context_from_env(&env_from(&[("TERM", "xterm-256color")]))
|
||||
.feedback_info();
|
||||
assert_eq!(absent.term_version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn term_program_version_wins_when_term_program_names_the_brand() {
|
||||
let (version, source) = resolved(&[
|
||||
|
|
|
|||
|
|
@ -96,7 +96,9 @@ pub fn probe_at_startup() {
|
|||
/// CSI-intercepting multiplexers skip — the innermost layer answers as
|
||||
/// itself, which the `multiplexer` field already records. Transparent muxes
|
||||
/// (e.g. cmux) need no special case.
|
||||
fn gate_allows_probe(ctx: &super::TerminalContext) -> bool {
|
||||
///
|
||||
/// `pub(super)` for [`super::da2`], which must stay disjoint from this list.
|
||||
pub(super) fn gate_allows_probe(ctx: &super::TerminalContext) -> bool {
|
||||
use super::TerminalName::*;
|
||||
matches!(
|
||||
ctx.brand,
|
||||
|
|
|
|||
Loading…
Reference in a new issue