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:
grokkybara[bot] 2026-07-28 22:50:19 +00:00
commit 5da6962e4a
192 changed files with 10337 additions and 3421 deletions

View file

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

View file

@ -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,

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

View file

@ -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:?}"
);
}
}
}

View file

@ -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 {

View file

@ -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(&[

View file

@ -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,