M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct NotificationConfig {
|
||||
pub method: NotificationMethod,
|
||||
pub condition: NotificationCondition,
|
||||
pub idle_threshold_secs: u64,
|
||||
pub events: Vec<NotificationEventKind>,
|
||||
pub sleep_prevention: bool,
|
||||
pub progress_bar: bool,
|
||||
/// Show an automatic "where was I" session recap when you return to the
|
||||
/// terminal after being away. Only applies when the shell has rolled out
|
||||
/// session recap (`sessionRecap` on ACP initialize / remote settings). Manual
|
||||
/// `/recap` is gated by the shell flag alone, not this toggle.
|
||||
pub session_recap: bool,
|
||||
/// Minimum seconds the terminal must be unfocused ("stepped away") before
|
||||
/// the client requests an automatic recap. A short debounce against quick
|
||||
/// tab blips; the authoritative timing ("≥3 min since the last completed
|
||||
/// turn") is enforced agent-side.
|
||||
pub session_recap_threshold_secs: u64,
|
||||
pub title: TitleConfig,
|
||||
pub hooks: Vec<NotificationHook>,
|
||||
}
|
||||
|
||||
impl Default for NotificationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
method: NotificationMethod::default(),
|
||||
condition: NotificationCondition::default(),
|
||||
idle_threshold_secs: 3,
|
||||
events: vec![
|
||||
NotificationEventKind::TurnComplete,
|
||||
NotificationEventKind::ApprovalRequired,
|
||||
],
|
||||
sleep_prevention: true,
|
||||
progress_bar: true,
|
||||
session_recap: true,
|
||||
session_recap_threshold_secs: 30,
|
||||
title: TitleConfig::default(),
|
||||
hooks: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NotificationMethod {
|
||||
#[default]
|
||||
Auto,
|
||||
Osc9,
|
||||
Osc99,
|
||||
Osc777,
|
||||
Bel,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NotificationCondition {
|
||||
#[default]
|
||||
Unfocused,
|
||||
Always,
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TitleConfig {
|
||||
pub enabled: bool,
|
||||
pub items: Vec<TitleItem>,
|
||||
}
|
||||
|
||||
impl Default for TitleConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
items: vec![
|
||||
TitleItem::ActionRequired,
|
||||
TitleItem::Spinner,
|
||||
TitleItem::Activity,
|
||||
TitleItem::SessionName,
|
||||
TitleItem::Grok,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum TitleItem {
|
||||
Spinner,
|
||||
Activity,
|
||||
SessionName,
|
||||
Cwd,
|
||||
Model,
|
||||
TurnTimer,
|
||||
Grok,
|
||||
ActionRequired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NotificationEventKind {
|
||||
TurnComplete,
|
||||
ApprovalRequired,
|
||||
SessionReady,
|
||||
TaskComplete,
|
||||
AgentError,
|
||||
}
|
||||
|
||||
impl NotificationEventKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::TurnComplete => "Turn complete",
|
||||
Self::ApprovalRequired => "Approval required",
|
||||
Self::SessionReady => "Session ready",
|
||||
Self::TaskComplete => "Task complete",
|
||||
Self::AgentError => "Agent error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
pub struct NotificationHook {
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub events: Vec<NotificationEventKind>,
|
||||
#[serde(default = "default_only_unfocused")]
|
||||
pub only_unfocused: bool,
|
||||
#[serde(default = "default_hook_timeout")]
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
fn default_only_unfocused() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_hook_timeout() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
impl NotificationConfig {
|
||||
/// Generate a commented TOML template for the `[ui.notifications]` section.
|
||||
///
|
||||
/// Mirrors the pattern used by `RawAppearanceConfig::to_toml_with_comments()`
|
||||
/// for `pager.toml`. The output is suitable for inclusion in documentation
|
||||
/// or as a starter config snippet.
|
||||
pub fn to_toml_with_comments() -> String {
|
||||
"\
|
||||
[ui.notifications]
|
||||
# Notification protocol: auto|osc9|osc99|osc777|bel|none
|
||||
# \"auto\" selects the best protocol for your terminal.
|
||||
method = \"auto\"
|
||||
# When to notify: unfocused|always|never
|
||||
# \"unfocused\" only fires when the terminal has lost focus.
|
||||
condition = \"unfocused\"
|
||||
# Minimum seconds the terminal must be unfocused before notifications fire.
|
||||
idle_threshold_secs = 3
|
||||
# Events that trigger notifications.
|
||||
# Options: turn_complete, approval_required, session_ready, task_complete, agent_error
|
||||
events = [\"turn_complete\", \"approval_required\"]
|
||||
# Prevent display sleep during agent turns (macOS/Linux).
|
||||
sleep_prevention = true
|
||||
# Show a progress indicator in the terminal tab (OSC 9;4).
|
||||
progress_bar = true
|
||||
# Show an automatic \"where was I\" session recap when you return after being away.
|
||||
# Shell session_recap is on by default; disable via [features] session_recap or
|
||||
# KIGI_SESSION_RECAP=0. Manual /recap uses only the shell flag.
|
||||
session_recap = true
|
||||
# Minimum seconds unfocused (\"stepped away\") before requesting a recap; a
|
||||
# debounce against quick tab blips. The \"3 min since the last turn\" timing is
|
||||
# enforced agent-side.
|
||||
session_recap_threshold_secs = 30
|
||||
|
||||
[ui.notifications.title]
|
||||
# Set the terminal/tab title to reflect agent state.
|
||||
enabled = true
|
||||
# Items shown in the title. Options: action-required, spinner, activity,
|
||||
# session-name, cwd, model, turn-timer, grok
|
||||
items = [\"action-required\", \"spinner\", \"activity\", \"session-name\", \"grok\"]
|
||||
|
||||
# [[ui.notifications.hooks]]
|
||||
# command = \"terminal-notifier -title 'Grok' -message '$KIGI_MESSAGE'\"
|
||||
# events = [\"turn_complete\", \"approval_required\"]
|
||||
# only_unfocused = true
|
||||
# timeout_secs = 10
|
||||
"
|
||||
.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_round_trips_through_toml() {
|
||||
let config = NotificationConfig {
|
||||
method: NotificationMethod::Osc99,
|
||||
condition: NotificationCondition::Always,
|
||||
idle_threshold_secs: 10,
|
||||
events: vec![
|
||||
NotificationEventKind::TurnComplete,
|
||||
NotificationEventKind::AgentError,
|
||||
],
|
||||
sleep_prevention: false,
|
||||
progress_bar: false,
|
||||
session_recap: false,
|
||||
session_recap_threshold_secs: 90,
|
||||
title: TitleConfig {
|
||||
enabled: false,
|
||||
items: vec![TitleItem::Grok, TitleItem::Cwd],
|
||||
},
|
||||
hooks: vec![NotificationHook {
|
||||
command: "notify-send".into(),
|
||||
events: vec![NotificationEventKind::TurnComplete],
|
||||
only_unfocused: false,
|
||||
timeout_secs: 5,
|
||||
}],
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string(&config).expect("serialize");
|
||||
let parsed: NotificationConfig = toml::from_str(&toml_str).expect("deserialize");
|
||||
|
||||
assert_eq!(config, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fields_use_defaults() {
|
||||
let parsed: NotificationConfig = toml::from_str("").expect("deserialize empty");
|
||||
|
||||
assert_eq!(parsed.method, NotificationMethod::Auto);
|
||||
assert_eq!(parsed.condition, NotificationCondition::Unfocused);
|
||||
assert_eq!(parsed.idle_threshold_secs, 3);
|
||||
assert_eq!(
|
||||
parsed.events,
|
||||
vec![
|
||||
NotificationEventKind::TurnComplete,
|
||||
NotificationEventKind::ApprovalRequired,
|
||||
]
|
||||
);
|
||||
assert!(parsed.sleep_prevention);
|
||||
assert!(parsed.progress_bar);
|
||||
assert!(parsed.session_recap);
|
||||
assert_eq!(parsed.session_recap_threshold_secs, 30);
|
||||
assert!(parsed.title.enabled);
|
||||
assert!(parsed.hooks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_toml_merges_with_defaults() {
|
||||
let toml_str = r#"
|
||||
method = "bel"
|
||||
idle_threshold_secs = 60
|
||||
"#;
|
||||
let parsed: NotificationConfig = toml::from_str(toml_str).expect("deserialize partial");
|
||||
|
||||
assert_eq!(parsed.method, NotificationMethod::Bel);
|
||||
assert_eq!(parsed.idle_threshold_secs, 60);
|
||||
// Rest should be defaults
|
||||
assert_eq!(parsed.condition, NotificationCondition::Unfocused);
|
||||
assert!(parsed.sleep_prevention);
|
||||
assert!(parsed.progress_bar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_defaults_applied() {
|
||||
let toml_str = r#"
|
||||
[[hooks]]
|
||||
command = "my-script.sh"
|
||||
"#;
|
||||
let parsed: NotificationConfig = toml::from_str(toml_str).expect("deserialize hooks");
|
||||
|
||||
assert_eq!(parsed.hooks.len(), 1);
|
||||
let hook = &parsed.hooks[0];
|
||||
assert_eq!(hook.command, "my-script.sh");
|
||||
assert!(hook.events.is_empty());
|
||||
assert!(hook.only_unfocused);
|
||||
assert_eq!(hook.timeout_secs, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_notification_methods_deserialize() {
|
||||
for (input, expected) in [
|
||||
("\"auto\"", NotificationMethod::Auto),
|
||||
("\"osc9\"", NotificationMethod::Osc9),
|
||||
("\"osc99\"", NotificationMethod::Osc99),
|
||||
("\"osc777\"", NotificationMethod::Osc777),
|
||||
("\"bel\"", NotificationMethod::Bel),
|
||||
("\"none\"", NotificationMethod::None),
|
||||
] {
|
||||
let parsed: NotificationMethod = toml::from_str(&format!("method = {input}\n"))
|
||||
.map(|c: MethodWrapper| c.method)
|
||||
.unwrap_or_else(|e| panic!("failed to parse {input}: {e}"));
|
||||
assert_eq!(parsed, expected, "mismatch for {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_conditions_deserialize() {
|
||||
for (input, expected) in [
|
||||
("\"unfocused\"", NotificationCondition::Unfocused),
|
||||
("\"always\"", NotificationCondition::Always),
|
||||
("\"never\"", NotificationCondition::Never),
|
||||
] {
|
||||
let parsed: NotificationCondition = toml::from_str(&format!("condition = {input}\n"))
|
||||
.map(|c: ConditionWrapper| c.condition)
|
||||
.unwrap_or_else(|e| panic!("failed to parse {input}: {e}"));
|
||||
assert_eq!(parsed, expected, "mismatch for {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_items_kebab_case() {
|
||||
let toml_str = r#"
|
||||
[title]
|
||||
enabled = true
|
||||
items = ["action-required", "turn-timer", "session-name"]
|
||||
"#;
|
||||
let parsed: NotificationConfig = toml::from_str(toml_str).expect("deserialize");
|
||||
assert_eq!(
|
||||
parsed.title.items,
|
||||
vec![
|
||||
TitleItem::ActionRequired,
|
||||
TitleItem::TurnTimer,
|
||||
TitleItem::SessionName,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_toml_with_comments_contains_all_sections() {
|
||||
let toml = NotificationConfig::to_toml_with_comments();
|
||||
assert!(toml.contains("[ui.notifications]"));
|
||||
assert!(toml.contains("[ui.notifications.title]"));
|
||||
assert!(toml.contains("[[ui.notifications.hooks]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_toml_with_comments_matches_defaults() {
|
||||
let template = NotificationConfig::to_toml_with_comments();
|
||||
let defaults = NotificationConfig::default();
|
||||
|
||||
assert!(
|
||||
template.contains(&format!(
|
||||
"idle_threshold_secs = {}",
|
||||
defaults.idle_threshold_secs
|
||||
)),
|
||||
"template idle_threshold_secs does not match default"
|
||||
);
|
||||
assert!(
|
||||
template.contains(&format!("sleep_prevention = {}", defaults.sleep_prevention)),
|
||||
"template sleep_prevention does not match default"
|
||||
);
|
||||
assert!(
|
||||
template.contains(&format!("progress_bar = {}", defaults.progress_bar)),
|
||||
"template progress_bar does not match default"
|
||||
);
|
||||
assert!(
|
||||
template.contains(&format!("enabled = {}", defaults.title.enabled)),
|
||||
"template title.enabled does not match default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_toml_with_comments_hook_section_is_valid_toml() {
|
||||
let template = NotificationConfig::to_toml_with_comments();
|
||||
// Uncomment only TOML structural lines (table headers and key = value).
|
||||
// Doc-comment lines (plain English) are left as comments so they
|
||||
// don't produce parse errors.
|
||||
let uncommented: String = template
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if let Some(stripped) = line.strip_prefix("# ") {
|
||||
let trimmed = stripped.trim_start();
|
||||
if trimmed.starts_with("[[") || trimmed.contains(" = ") {
|
||||
return format!("{stripped}\n");
|
||||
}
|
||||
}
|
||||
format!("{line}\n")
|
||||
})
|
||||
.collect();
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&uncommented).expect("uncommented template should be valid TOML");
|
||||
let hooks = parsed
|
||||
.get("ui")
|
||||
.and_then(|u| u.get("notifications"))
|
||||
.and_then(|n| n.get("hooks"))
|
||||
.expect("hooks key must exist after uncommenting");
|
||||
assert!(hooks.is_array(), "hooks should be an array of tables");
|
||||
}
|
||||
|
||||
// Helper wrappers for single-field deserialization tests
|
||||
#[derive(Deserialize)]
|
||||
struct MethodWrapper {
|
||||
method: NotificationMethod,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConditionWrapper {
|
||||
condition: NotificationCondition,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
use std::cell::Cell;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Minimum gap between automatic recap *attempts* while still away. The shell
|
||||
/// may no-op early requests (<3 min since last turn, etc.); we must retry later
|
||||
/// without hammering every 20s poll.
|
||||
const AUTO_RECAP_RETRY_INTERVAL: Duration = Duration::from_secs(90);
|
||||
|
||||
pub struct FocusTracker {
|
||||
focused: Cell<bool>,
|
||||
lost_at: Cell<Option<Instant>>,
|
||||
idle_threshold: Duration,
|
||||
/// Minimum unfocused time before an automatic session recap is offered on
|
||||
/// return. See [`FocusTracker::recap_due`].
|
||||
recap_threshold: Duration,
|
||||
/// Whether an automatic recap has already been *shown* for the current away
|
||||
/// period (set when a `SessionRecap` notification arrives). Cleared on focus
|
||||
/// loss. Stops further requests for this away period once the user has a recap.
|
||||
recap_shown_this_away: Cell<bool>,
|
||||
/// Last time we dispatched an automatic recap request (pre-gen or focus-gained).
|
||||
/// Used for retry backoff while waiting for shell gates (e.g. 3 min since last turn).
|
||||
last_auto_recap_attempt_at: Cell<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl FocusTracker {
|
||||
pub fn new(idle_threshold_secs: u64, recap_threshold_secs: u64) -> Self {
|
||||
Self {
|
||||
focused: Cell::new(true),
|
||||
lost_at: Cell::new(None),
|
||||
idle_threshold: Duration::from_secs(idle_threshold_secs),
|
||||
recap_threshold: Duration::from_secs(recap_threshold_secs),
|
||||
recap_shown_this_away: Cell::new(false),
|
||||
last_auto_recap_attempt_at: Cell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_focus_gained(&self) {
|
||||
self.focused.set(true);
|
||||
self.lost_at.set(None);
|
||||
}
|
||||
|
||||
pub fn on_focus_lost(&self) {
|
||||
self.focused.set(false);
|
||||
self.lost_at.set(Some(Instant::now()));
|
||||
// A fresh away period begins — re-arm auto recap.
|
||||
self.recap_shown_this_away.set(false);
|
||||
self.last_auto_recap_attempt_at.set(None);
|
||||
}
|
||||
|
||||
pub fn should_notify(&self) -> bool {
|
||||
if self.focused.get() {
|
||||
return false;
|
||||
}
|
||||
match self.lost_at.get() {
|
||||
Some(lost) => lost.elapsed() >= self.idle_threshold,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_focused(&self) -> bool {
|
||||
self.focused.get()
|
||||
}
|
||||
|
||||
/// `true` if an automatic session recap request should be sent: unfocused
|
||||
/// past the recap threshold, no successful recap shown this away period,
|
||||
/// and not within the retry backoff after a recent attempt.
|
||||
///
|
||||
/// Shell gates (≥3 turns, ≥3 min since last main turn, never twice in a
|
||||
/// row) are authoritative; early attempts may no-op, so we retry on a
|
||||
/// 90s interval until shown or focus returns.
|
||||
pub fn recap_due(&self) -> bool {
|
||||
if self.focused.get() || self.recap_shown_this_away.get() {
|
||||
return false;
|
||||
}
|
||||
if let Some(last) = self.last_auto_recap_attempt_at.get()
|
||||
&& last.elapsed() < AUTO_RECAP_RETRY_INTERVAL
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match self.lost_at.get() {
|
||||
Some(lost) => lost.elapsed() >= self.recap_threshold,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record that an automatic recap was dispatched (pre-gen or focus-gained).
|
||||
/// Does **not** consume the away period — only starts retry backoff so we
|
||||
/// do not spam every poll while the shell still rejects (e.g. <3 min idle).
|
||||
pub fn note_auto_recap_attempt(&self) {
|
||||
self.last_auto_recap_attempt_at.set(Some(Instant::now()));
|
||||
}
|
||||
|
||||
/// Record that a recap was shown (auto or manual `/recap`) for the current
|
||||
/// away period. Stops further **auto** requests until focus is lost again.
|
||||
/// Manual `/recap` may still be invoked repeatedly.
|
||||
pub fn mark_recap_shown(&self) {
|
||||
self.recap_shown_this_away.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn newly_created_tracker_is_focused() {
|
||||
let tracker = FocusTracker::new(3, 180);
|
||||
assert!(tracker.is_focused());
|
||||
assert!(!tracker.should_notify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_notify_immediately_after_focus_lost() {
|
||||
let tracker = FocusTracker::new(3, 180);
|
||||
tracker.on_focus_lost();
|
||||
assert!(!tracker.is_focused());
|
||||
assert!(!tracker.should_notify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_notify_after_threshold_elapsed() {
|
||||
let tracker = FocusTracker::new(0, 180);
|
||||
tracker.on_focus_lost();
|
||||
// With a 0-second threshold, should_notify is true immediately
|
||||
assert!(tracker.should_notify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_notify_when_refocused_after_threshold() {
|
||||
let tracker = FocusTracker::new(0, 180);
|
||||
tracker.on_focus_lost();
|
||||
assert!(tracker.should_notify());
|
||||
tracker.on_focus_gained();
|
||||
assert!(tracker.is_focused());
|
||||
assert!(!tracker.should_notify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rapid_focus_toggle_resets_timer() {
|
||||
let tracker = FocusTracker::new(60, 180);
|
||||
tracker.on_focus_lost();
|
||||
tracker.on_focus_gained();
|
||||
tracker.on_focus_lost();
|
||||
// Timer restarted on the second loss, so threshold is far away
|
||||
assert!(!tracker.should_notify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_boundary_with_manual_instant() {
|
||||
let tracker = FocusTracker::new(5, 180);
|
||||
// Simulate loss in the past by directly setting lost_at
|
||||
tracker.focused.set(false);
|
||||
tracker
|
||||
.lost_at
|
||||
.set(Some(Instant::now() - Duration::from_secs(6)));
|
||||
assert!(tracker.should_notify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_boundary_not_yet_reached() {
|
||||
let tracker = FocusTracker::new(5, 180);
|
||||
tracker.focused.set(false);
|
||||
tracker
|
||||
.lost_at
|
||||
.set(Some(Instant::now() - Duration::from_secs(2)));
|
||||
assert!(!tracker.should_notify());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_gained_clears_lost_at() {
|
||||
let tracker = FocusTracker::new(0, 180);
|
||||
tracker.on_focus_lost();
|
||||
assert!(tracker.should_notify());
|
||||
tracker.on_focus_gained();
|
||||
// lost_at is None after regain, so even unfocused state would return false
|
||||
assert!(tracker.is_focused());
|
||||
assert!(tracker.lost_at.get().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_focus_lost_calls_update_timestamp() {
|
||||
let tracker = FocusTracker::new(5, 180);
|
||||
tracker.focused.set(false);
|
||||
let old = Instant::now() - Duration::from_secs(10);
|
||||
tracker.lost_at.set(Some(old));
|
||||
assert!(tracker.should_notify());
|
||||
|
||||
// Second on_focus_lost resets the timer
|
||||
tracker.on_focus_lost();
|
||||
assert!(!tracker.should_notify());
|
||||
}
|
||||
|
||||
// --- Auto session-recap (recap_due) tests ---
|
||||
|
||||
#[test]
|
||||
fn recap_not_due_while_focused() {
|
||||
let tracker = FocusTracker::new(3, 0);
|
||||
assert!(!tracker.recap_due(), "focused terminal is never away");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recap_not_due_immediately_after_focus_lost() {
|
||||
let tracker = FocusTracker::new(3, 180);
|
||||
tracker.on_focus_lost();
|
||||
assert!(!tracker.recap_due(), "not away long enough yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recap_due_after_away_threshold() {
|
||||
let tracker = FocusTracker::new(3, 5);
|
||||
tracker.focused.set(false);
|
||||
tracker
|
||||
.lost_at
|
||||
.set(Some(Instant::now() - Duration::from_secs(6)));
|
||||
assert!(tracker.recap_due());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recap_due_respects_independent_threshold() {
|
||||
// idle (notification) threshold is 0, but recap threshold is large:
|
||||
// a brief away period must not be recap-eligible.
|
||||
let tracker = FocusTracker::new(0, 180);
|
||||
tracker.on_focus_lost();
|
||||
assert!(tracker.should_notify(), "notification fires immediately");
|
||||
assert!(!tracker.recap_due(), "recap waits for its own threshold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recap_due_stops_after_shown() {
|
||||
let tracker = FocusTracker::new(3, 0);
|
||||
tracker.on_focus_lost();
|
||||
assert!(tracker.recap_due());
|
||||
tracker.mark_recap_shown();
|
||||
assert!(
|
||||
!tracker.recap_due(),
|
||||
"must not request again once recap is on screen"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recap_re_arms_after_new_away_period() {
|
||||
let tracker = FocusTracker::new(3, 0);
|
||||
tracker.on_focus_lost();
|
||||
tracker.mark_recap_shown();
|
||||
assert!(!tracker.recap_due());
|
||||
// Return, then leave again — a new away period re-arms the recap.
|
||||
tracker.on_focus_gained();
|
||||
tracker.on_focus_lost();
|
||||
assert!(tracker.recap_due());
|
||||
}
|
||||
|
||||
/// Early dispatch must not consume the away period (shell may no-op until
|
||||
/// ≥3 min since last turn). Only backoff applies; after the interval we retry.
|
||||
#[test]
|
||||
fn recap_due_backoff_after_attempt_allows_retry() {
|
||||
let tracker = FocusTracker::new(3, 0);
|
||||
tracker.on_focus_lost();
|
||||
assert!(tracker.recap_due());
|
||||
tracker.note_auto_recap_attempt();
|
||||
assert!(
|
||||
!tracker.recap_due(),
|
||||
"must not re-fire on the next 20s poll"
|
||||
);
|
||||
// Simulate retry interval elapsed without a successful notification.
|
||||
tracker.last_auto_recap_attempt_at.set(Some(
|
||||
Instant::now() - AUTO_RECAP_RETRY_INTERVAL - Duration::from_secs(1),
|
||||
));
|
||||
assert!(
|
||||
tracker.recap_due(),
|
||||
"shell may accept once 3 min since last turn; pager must retry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recap_due_shown_wins_over_retry_backoff() {
|
||||
let tracker = FocusTracker::new(3, 0);
|
||||
tracker.on_focus_lost();
|
||||
tracker.note_auto_recap_attempt();
|
||||
tracker.last_auto_recap_attempt_at.set(Some(
|
||||
Instant::now() - AUTO_RECAP_RETRY_INTERVAL - Duration::from_secs(1),
|
||||
));
|
||||
tracker.mark_recap_shown();
|
||||
assert!(!tracker.recap_due(), "shown recap must not retry");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::notifications::NotificationEvent;
|
||||
use crate::notifications::config::NotificationHook;
|
||||
|
||||
fn execute_hook(
|
||||
command: &str,
|
||||
event_str: &str,
|
||||
message: &str,
|
||||
session_id: Option<&str>,
|
||||
timeout: Duration,
|
||||
) {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c")
|
||||
.arg(command)
|
||||
.env("KIGI_EVENT", event_str)
|
||||
.env("KIGI_MESSAGE", message)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
if let Some(sid) = session_id {
|
||||
cmd.env("KIGI_SESSION_ID", sid);
|
||||
}
|
||||
|
||||
// Create a new process group so we can kill the entire tree on timeout,
|
||||
// preventing orphaned subprocesses from accumulating.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
// SAFETY: setsid is async-signal-safe per POSIX and does not
|
||||
// allocate or take locks. Called between fork and exec.
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
nix::unistd::setsid().ok();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok(mut child) => {
|
||||
use wait_timeout::ChildExt;
|
||||
match child.wait_timeout(timeout) {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
// Kill the entire process group, not just the direct child.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pid = child.id() as i32;
|
||||
let _ = nix::sys::signal::killpg(
|
||||
nix::unistd::Pid::from_raw(pid),
|
||||
nix::sys::signal::Signal::SIGKILL,
|
||||
);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
let _ = child.wait();
|
||||
tracing::warn!("hook timed out");
|
||||
}
|
||||
Err(e) => tracing::debug!(error = %e, command, "hook wait failed"),
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!(error = %e, command, "hook spawn failed"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_hook(hook: &NotificationHook, event: &NotificationEvent) {
|
||||
let command = hook.command.clone();
|
||||
let event_str: &'static str = event.kind.as_str();
|
||||
let message = event.body.clone();
|
||||
let session_id = event.session_id.clone();
|
||||
let timeout = Duration::from_secs(hook.timeout_secs.max(1));
|
||||
|
||||
std::thread::spawn(move || {
|
||||
execute_hook(
|
||||
&command,
|
||||
event_str,
|
||||
&message,
|
||||
session_id.as_deref(),
|
||||
timeout,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::notifications::config::NotificationEventKind;
|
||||
use std::time::Instant;
|
||||
|
||||
fn test_event() -> NotificationEvent {
|
||||
NotificationEvent {
|
||||
kind: NotificationEventKind::TurnComplete,
|
||||
title: "Grok".into(),
|
||||
body: "test body payload".into(),
|
||||
session_id: Some("test-session-123".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sets_environment_variables() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("env.txt");
|
||||
let command = format!(
|
||||
"printf 'KIGI_EVENT=%s\\nKIGI_MESSAGE=%s\\nKIGI_SESSION_ID=%s\\n' \
|
||||
\"$KIGI_EVENT\" \"$KIGI_MESSAGE\" \"$KIGI_SESSION_ID\" > {}",
|
||||
out.display()
|
||||
);
|
||||
|
||||
execute_hook(
|
||||
&command,
|
||||
"Turn complete",
|
||||
"hello world",
|
||||
Some("sess-42"),
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
let content = std::fs::read_to_string(&out).unwrap();
|
||||
assert!(
|
||||
content.contains("KIGI_EVENT=Turn complete"),
|
||||
"missing KIGI_EVENT: {content}"
|
||||
);
|
||||
assert!(
|
||||
content.contains("KIGI_MESSAGE=hello world"),
|
||||
"missing KIGI_MESSAGE: {content}"
|
||||
);
|
||||
assert!(
|
||||
content.contains("KIGI_SESSION_ID=sess-42"),
|
||||
"missing KIGI_SESSION_ID: {content}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_session_id_when_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("env.txt");
|
||||
let command = format!("env > {}", out.display());
|
||||
|
||||
execute_hook(
|
||||
&command,
|
||||
"Turn complete",
|
||||
"msg",
|
||||
None,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
let content = std::fs::read_to_string(&out).unwrap();
|
||||
assert!(
|
||||
!content.contains("KIGI_SESSION_ID"),
|
||||
"KIGI_SESSION_ID should not be set: {content}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kills_on_timeout() {
|
||||
let start = Instant::now();
|
||||
execute_hook(
|
||||
"sleep 100",
|
||||
"Turn complete",
|
||||
"msg",
|
||||
None,
|
||||
Duration::from_secs(1),
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(3),
|
||||
"should return within timeout, took {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_failed_shell_command_gracefully() {
|
||||
execute_hook(
|
||||
"/nonexistent/path/binary",
|
||||
"Turn complete",
|
||||
"msg",
|
||||
None,
|
||||
Duration::from_secs(1),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_nonzero_exit_gracefully() {
|
||||
execute_hook(
|
||||
"exit 1",
|
||||
"Turn complete",
|
||||
"msg",
|
||||
None,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_command_completes_without_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = dir.path().join("done");
|
||||
let command = format!("touch {}", marker.display());
|
||||
|
||||
execute_hook(
|
||||
&command,
|
||||
"Turn complete",
|
||||
"msg",
|
||||
None,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
assert!(marker.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_hook_spawns_thread_without_panic() {
|
||||
let hook = NotificationHook {
|
||||
command: "true".into(),
|
||||
events: vec![],
|
||||
only_unfocused: false,
|
||||
timeout_secs: 5,
|
||||
};
|
||||
run_hook(&hook, &test_event());
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_clamped_to_minimum_one_second() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let marker = dir.path().join("done");
|
||||
let hook = NotificationHook {
|
||||
command: format!("sleep 100; touch {}", marker.display()),
|
||||
events: vec![],
|
||||
only_unfocused: false,
|
||||
timeout_secs: 0, // exercises the .max(1) clamp inside run_hook
|
||||
};
|
||||
let start = Instant::now();
|
||||
run_hook(&hook, &test_event());
|
||||
// Wait for the spawned thread to finish (clamp turns 0 -> 1s timeout)
|
||||
std::thread::sleep(Duration::from_millis(2500));
|
||||
let elapsed = start.elapsed();
|
||||
// The hook should have been killed by the 1s timeout, so the marker
|
||||
// file should NOT exist (sleep 100 never completes).
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"hook should have been killed by timeout before creating marker"
|
||||
);
|
||||
// Sanity: the whole thing completed well under 10s, confirming the
|
||||
// timeout was ~1s (clamped) not 0s (instant) or unbounded.
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(5),
|
||||
"should complete within a few seconds, took {elapsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_hook_passes_correct_env_via_thread() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("env.txt");
|
||||
let hook = NotificationHook {
|
||||
command: format!(
|
||||
"printf 'KIGI_EVENT=%s\\nKIGI_MESSAGE=%s\\nKIGI_SESSION_ID=%s\\n' \
|
||||
\"$KIGI_EVENT\" \"$KIGI_MESSAGE\" \"$KIGI_SESSION_ID\" > {}",
|
||||
out.display()
|
||||
),
|
||||
events: vec![],
|
||||
only_unfocused: false,
|
||||
timeout_secs: 5,
|
||||
};
|
||||
let event = test_event();
|
||||
run_hook(&hook, &event);
|
||||
|
||||
// Poll for the output file instead of a fixed sleep — the spawned
|
||||
// thread + fork/exec may take variable time on loaded systems.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let content = loop {
|
||||
if let Ok(c) = std::fs::read_to_string(&out) {
|
||||
break c;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"hook did not produce output file within 5s (sh or printf may not be available)"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
};
|
||||
assert!(content.contains("KIGI_EVENT=Turn complete"));
|
||||
assert!(content.contains("KIGI_MESSAGE=test body payload"));
|
||||
assert!(content.contains("KIGI_SESSION_ID=test-session-123"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
pub mod config;
|
||||
pub mod focus;
|
||||
pub mod hooks;
|
||||
pub mod progress;
|
||||
pub mod protocol;
|
||||
pub mod sleep;
|
||||
pub mod title;
|
||||
pub mod tmux;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Ghostty resets the OSC 9;4 progress indicator after ~15 s of silence.
|
||||
/// Re-send the sequence at this interval to keep it alive.
|
||||
const PROGRESS_KEEPALIVE: Duration = Duration::from_secs(5);
|
||||
|
||||
pub use config::{
|
||||
NotificationCondition, NotificationConfig, NotificationEventKind, NotificationHook,
|
||||
NotificationMethod, TitleConfig, TitleItem,
|
||||
};
|
||||
pub use title::TitleState;
|
||||
|
||||
pub struct NotificationEvent {
|
||||
pub kind: NotificationEventKind,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct NotificationService {
|
||||
config: NotificationConfig,
|
||||
pub focus_tracker: focus::FocusTracker,
|
||||
pub sleep_inhibitor: sleep::SleepInhibitor,
|
||||
title_manager: title::TitleManager,
|
||||
protocol: protocol::NotificationProtocol,
|
||||
terminal_ctx: &'static crate::terminal::TerminalContext,
|
||||
/// Whether the OSC 9;4 progress indicator is currently active.
|
||||
progress_active: bool,
|
||||
/// Last time the progress bar escape was emitted (keep-alive clock).
|
||||
progress_last_sent: Option<Instant>,
|
||||
/// Whether we have already fired an `ApprovalRequired` terminal
|
||||
/// notification for the current batch of queued permissions. Set to
|
||||
/// `true` after the first notification; cleared via
|
||||
/// [`clear_permission_notification`] when the queue drains to empty.
|
||||
permission_notified: bool,
|
||||
}
|
||||
|
||||
impl NotificationService {
|
||||
pub fn new(config: NotificationConfig) -> Self {
|
||||
let terminal_ctx = crate::terminal::terminal_context();
|
||||
let protocol = resolve_protocol(config.method, terminal_ctx);
|
||||
let focus_tracker = focus::FocusTracker::new(
|
||||
config.idle_threshold_secs,
|
||||
config.session_recap_threshold_secs,
|
||||
);
|
||||
let sleep_inhibitor = sleep::SleepInhibitor::new(config.sleep_prevention);
|
||||
let title_manager = title::TitleManager::new(&config.title);
|
||||
Self {
|
||||
config,
|
||||
focus_tracker,
|
||||
sleep_inhibitor,
|
||||
title_manager,
|
||||
protocol,
|
||||
terminal_ctx,
|
||||
progress_active: false,
|
||||
progress_last_sent: None,
|
||||
permission_notified: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &NotificationConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub fn protocol(&self) -> protocol::NotificationProtocol {
|
||||
self.protocol
|
||||
}
|
||||
|
||||
fn is_event_enabled(&self, kind: &NotificationEventKind) -> bool {
|
||||
self.config.events.contains(kind)
|
||||
}
|
||||
|
||||
fn should_emit_terminal(&self) -> bool {
|
||||
match self.config.condition {
|
||||
NotificationCondition::Always => true,
|
||||
NotificationCondition::Unfocused => self.focus_tracker.should_notify(),
|
||||
NotificationCondition::Never => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire a one-shot terminal notification (bell/OSC popup).
|
||||
///
|
||||
/// These escape sequences intentionally bypass the frame pipeline and
|
||||
/// write directly to stderr. Unlike per-tick title/progress updates,
|
||||
/// notifications are rare, one-shot events (turn complete, agent error)
|
||||
/// that must reach the terminal immediately — deferring them to the
|
||||
/// next draw frame would add up to 16ms latency for no user-visible
|
||||
/// benefit, and the sequences are short enough that interleaving with
|
||||
/// frame data does not produce visible artefacts.
|
||||
///
|
||||
/// For `ApprovalRequired` events, the caller must check
|
||||
/// [`should_suppress_permission_notification`] first and call
|
||||
/// [`mark_permission_notified`] after a successful emit to avoid
|
||||
/// repeated bells during concurrent permission requests.
|
||||
pub fn notify(&self, event: NotificationEvent) {
|
||||
if !self.is_event_enabled(&event.kind) {
|
||||
return;
|
||||
}
|
||||
if self.should_emit_terminal() {
|
||||
protocol::emit_notification(
|
||||
self.protocol,
|
||||
&event.title,
|
||||
&event.body,
|
||||
self.terminal_ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the tab title and progress bar to the idle state, writing
|
||||
/// directly to stderr. Call before `notify()` so that Ghostty's
|
||||
/// notification popup picks up the updated (non-spinning) title
|
||||
/// instead of a stale "Responding" subtitle.
|
||||
pub fn flush_idle_state(&mut self, state: &title::TitleState<'_>) {
|
||||
let mut buf = String::new();
|
||||
|
||||
if self.config.title.enabled
|
||||
&& let Some(esc) = self.title_manager.update(state)
|
||||
{
|
||||
buf.push_str(&esc);
|
||||
}
|
||||
|
||||
if !state.is_busy {
|
||||
self.clear_progress_into(&mut buf);
|
||||
}
|
||||
|
||||
if !buf.is_empty() {
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
use std::io::Write;
|
||||
let _ = stderr.write_all(buf.as_bytes());
|
||||
let _ = stderr.flush();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Build escape sequences to set the title and progress bar to idle
|
||||
/// without writing to stderr. The caller can route these through the
|
||||
/// frame pipeline (`pending_notification_escapes`) so they go through
|
||||
/// the writer thread and are ordered correctly relative to previous
|
||||
/// frames that may still carry the busy title.
|
||||
pub fn build_idle_escapes(&mut self, state: &title::TitleState<'_>) -> Option<String> {
|
||||
let mut buf = String::new();
|
||||
|
||||
if self.config.title.enabled
|
||||
&& let Some(esc) = self.title_manager.update(state)
|
||||
{
|
||||
buf.push_str(&esc);
|
||||
}
|
||||
|
||||
if !state.is_busy {
|
||||
self.clear_progress_into(&mut buf);
|
||||
}
|
||||
|
||||
if buf.is_empty() { None } else { Some(buf) }
|
||||
}
|
||||
|
||||
/// Advance the tab title and progress bar state.
|
||||
///
|
||||
/// Returns escape sequences to emit (title + progress) as a single
|
||||
/// `String`, or `None` if nothing changed. The caller should route
|
||||
/// these bytes through the frame pipeline's `post_flush_escapes`.
|
||||
pub fn on_tick(&mut self, state: &title::TitleState<'_>) -> Option<String> {
|
||||
let mut buf = String::new();
|
||||
|
||||
if self.config.title.enabled
|
||||
&& let Some(title_esc) = self.title_manager.update(state)
|
||||
{
|
||||
buf.push_str(&title_esc);
|
||||
}
|
||||
|
||||
// Drive OSC 9;4 tab progress bar. Ghostty resets the indicator
|
||||
// after ~15 s of silence, so we re-send it as a keep-alive.
|
||||
if self.config.progress_bar {
|
||||
let should_be_active = state.is_busy;
|
||||
if should_be_active {
|
||||
let needs_emit = !self.progress_active
|
||||
|| self
|
||||
.progress_last_sent
|
||||
.is_none_or(|t| t.elapsed() >= PROGRESS_KEEPALIVE);
|
||||
if needs_emit {
|
||||
if let Some(esc) = progress::build_progress_escape(
|
||||
progress::ProgressState::Indeterminate,
|
||||
self.terminal_ctx,
|
||||
) {
|
||||
buf.push_str(&esc);
|
||||
}
|
||||
self.progress_active = true;
|
||||
self.progress_last_sent = Some(Instant::now());
|
||||
}
|
||||
} else {
|
||||
self.clear_progress_into(&mut buf);
|
||||
}
|
||||
}
|
||||
|
||||
if buf.is_empty() { None } else { Some(buf) }
|
||||
}
|
||||
|
||||
pub fn shutdown(&mut self) {
|
||||
// Reset the tab title back to "grok" so it doesn't linger on the
|
||||
// last activity label after exit.
|
||||
let title_esc = self.title_manager.reset();
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
use std::io::Write as _;
|
||||
let _ = stderr.write_all(title_esc.as_bytes());
|
||||
let _ = stderr.flush();
|
||||
});
|
||||
|
||||
let mut buf = String::new();
|
||||
self.clear_progress_into(&mut buf);
|
||||
if !buf.is_empty() {
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
use std::io::Write as _;
|
||||
let _ = stderr.write_all(buf.as_bytes());
|
||||
let _ = stderr.flush();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if a terminal notification for `ApprovalRequired` has
|
||||
/// already been emitted and should not be repeated.
|
||||
pub fn should_suppress_permission_notification(&self) -> bool {
|
||||
self.permission_notified
|
||||
}
|
||||
|
||||
/// Record that an `ApprovalRequired` notification has been emitted.
|
||||
pub fn mark_permission_notified(&mut self) {
|
||||
self.permission_notified = true;
|
||||
}
|
||||
|
||||
/// Reset the permission notification flag. Call this when the permission
|
||||
/// queue drains to empty.
|
||||
pub fn clear_permission_notification(&mut self) {
|
||||
self.permission_notified = false;
|
||||
}
|
||||
|
||||
fn clear_progress_into(&mut self, buf: &mut String) {
|
||||
if !self.progress_active {
|
||||
return;
|
||||
}
|
||||
if let Some(esc) =
|
||||
progress::build_progress_escape(progress::ProgressState::Clear, self.terminal_ctx)
|
||||
{
|
||||
buf.push_str(&esc);
|
||||
}
|
||||
self.progress_active = false;
|
||||
self.progress_last_sent = None;
|
||||
}
|
||||
|
||||
/// Whether the OSC 9;4 progress indicator is currently considered active.
|
||||
/// Test-only: production callers drive progress exclusively via
|
||||
/// [`Self::on_tick`] / [`Self::build_idle_escapes`] / [`Self::flush_idle_state`].
|
||||
#[cfg(test)]
|
||||
pub(crate) fn is_progress_active(&self) -> bool {
|
||||
self.progress_active
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_for_test(config: NotificationConfig) -> Self {
|
||||
let terminal_ctx = crate::terminal::terminal_context();
|
||||
let focus_tracker = focus::FocusTracker::new(
|
||||
config.idle_threshold_secs,
|
||||
config.session_recap_threshold_secs,
|
||||
);
|
||||
let sleep_inhibitor = sleep::SleepInhibitor::new(config.sleep_prevention);
|
||||
let title_manager = title::TitleManager::new(&config.title);
|
||||
Self {
|
||||
config,
|
||||
focus_tracker,
|
||||
sleep_inhibitor,
|
||||
title_manager,
|
||||
protocol: protocol::NotificationProtocol::None,
|
||||
terminal_ctx,
|
||||
progress_active: false,
|
||||
progress_last_sent: None,
|
||||
permission_notified: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_protocol(
|
||||
method: NotificationMethod,
|
||||
ctx: &crate::terminal::TerminalContext,
|
||||
) -> protocol::NotificationProtocol {
|
||||
match method {
|
||||
NotificationMethod::Auto => protocol::select_protocol(ctx),
|
||||
NotificationMethod::Osc9 => protocol::NotificationProtocol::Osc9,
|
||||
NotificationMethod::Osc99 => protocol::NotificationProtocol::Osc99,
|
||||
NotificationMethod::Osc777 => protocol::NotificationProtocol::Osc777,
|
||||
NotificationMethod::Bel => protocol::NotificationProtocol::Bel,
|
||||
NotificationMethod::None => protocol::NotificationProtocol::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load `NotificationConfig` from a raw TOML config value.
|
||||
///
|
||||
/// Looks for `[ui.notifications]`; falls back to defaults if absent or
|
||||
/// malformed.
|
||||
pub fn load_notification_config(raw_config: &toml::Value) -> NotificationConfig {
|
||||
raw_config
|
||||
.get("ui")
|
||||
.and_then(|ui| ui.get("notifications"))
|
||||
.and_then(|n| toml::to_string(n).ok())
|
||||
.and_then(|s| toml::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal::{TerminalContext, TerminalName};
|
||||
|
||||
#[test]
|
||||
fn resolve_protocol_auto_delegates_to_select() {
|
||||
let ctx = TerminalContext {
|
||||
brand: TerminalName::Kitty,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_protocol(NotificationMethod::Auto, &ctx),
|
||||
protocol::NotificationProtocol::Osc99,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_protocol_explicit_overrides_auto_detection() {
|
||||
let ctx = TerminalContext {
|
||||
brand: TerminalName::Kitty,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_protocol(NotificationMethod::Bel, &ctx),
|
||||
protocol::NotificationProtocol::Bel,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_protocol_all_explicit_methods() {
|
||||
let ctx = TerminalContext::default();
|
||||
let cases = [
|
||||
(
|
||||
NotificationMethod::Osc9,
|
||||
protocol::NotificationProtocol::Osc9,
|
||||
),
|
||||
(
|
||||
NotificationMethod::Osc99,
|
||||
protocol::NotificationProtocol::Osc99,
|
||||
),
|
||||
(
|
||||
NotificationMethod::Osc777,
|
||||
protocol::NotificationProtocol::Osc777,
|
||||
),
|
||||
(NotificationMethod::Bel, protocol::NotificationProtocol::Bel),
|
||||
(
|
||||
NotificationMethod::None,
|
||||
protocol::NotificationProtocol::None,
|
||||
),
|
||||
];
|
||||
for (method, expected) in cases {
|
||||
assert_eq!(
|
||||
resolve_protocol(method, &ctx),
|
||||
expected,
|
||||
"mismatch for {method:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_event_enabled_filters_absent_kinds() {
|
||||
let svc = NotificationService::new_for_test(NotificationConfig {
|
||||
events: vec![NotificationEventKind::TurnComplete],
|
||||
condition: NotificationCondition::Always,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(!svc.is_event_enabled(&NotificationEventKind::SessionReady));
|
||||
assert!(!svc.is_event_enabled(&NotificationEventKind::AgentError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_event_enabled_allows_present_kinds() {
|
||||
let svc = NotificationService::new_for_test(NotificationConfig {
|
||||
events: vec![
|
||||
NotificationEventKind::TurnComplete,
|
||||
NotificationEventKind::AgentError,
|
||||
],
|
||||
condition: NotificationCondition::Always,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(svc.is_event_enabled(&NotificationEventKind::TurnComplete));
|
||||
assert!(svc.is_event_enabled(&NotificationEventKind::AgentError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_emit_terminal_never_blocks() {
|
||||
let svc = NotificationService::new_for_test(NotificationConfig {
|
||||
condition: NotificationCondition::Never,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(!svc.should_emit_terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_emit_terminal_always_fires_when_focused() {
|
||||
let svc = NotificationService::new_for_test(NotificationConfig {
|
||||
condition: NotificationCondition::Always,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(svc.focus_tracker.is_focused());
|
||||
assert!(svc.should_emit_terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_emit_terminal_unfocused_blocks_when_focused() {
|
||||
let svc = NotificationService::new_for_test(NotificationConfig {
|
||||
condition: NotificationCondition::Unfocused,
|
||||
idle_threshold_secs: 0,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(svc.focus_tracker.is_focused());
|
||||
assert!(!svc.should_emit_terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_emit_terminal_unfocused_fires_when_idle() {
|
||||
let svc = NotificationService::new_for_test(NotificationConfig {
|
||||
condition: NotificationCondition::Unfocused,
|
||||
idle_threshold_secs: 0,
|
||||
..Default::default()
|
||||
});
|
||||
svc.focus_tracker.on_focus_lost();
|
||||
assert!(svc.should_emit_terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_emit_terminal_unfocused_respects_idle_threshold() {
|
||||
let svc = NotificationService::new_for_test(NotificationConfig {
|
||||
condition: NotificationCondition::Unfocused,
|
||||
idle_threshold_secs: 60,
|
||||
..Default::default()
|
||||
});
|
||||
svc.focus_tracker.on_focus_lost();
|
||||
assert!(!svc.should_emit_terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_emit_terminal_refocus_stops_notifications() {
|
||||
let svc = NotificationService::new_for_test(NotificationConfig {
|
||||
condition: NotificationCondition::Unfocused,
|
||||
idle_threshold_secs: 0,
|
||||
..Default::default()
|
||||
});
|
||||
svc.focus_tracker.on_focus_lost();
|
||||
assert!(svc.should_emit_terminal());
|
||||
svc.focus_tracker.on_focus_gained();
|
||||
assert!(!svc.should_emit_terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_no_panic_with_none_protocol() {
|
||||
let svc = NotificationService::new_for_test(NotificationConfig {
|
||||
events: vec![NotificationEventKind::TurnComplete],
|
||||
condition: NotificationCondition::Always,
|
||||
..Default::default()
|
||||
});
|
||||
svc.notify(NotificationEvent {
|
||||
kind: NotificationEventKind::TurnComplete,
|
||||
title: "Grok".into(),
|
||||
body: "Turn complete".into(),
|
||||
session_id: Some("test-session".into()),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_skips_filtered_event() {
|
||||
let svc = NotificationService::new_for_test(NotificationConfig {
|
||||
events: vec![NotificationEventKind::TurnComplete],
|
||||
condition: NotificationCondition::Always,
|
||||
..Default::default()
|
||||
});
|
||||
svc.notify(NotificationEvent {
|
||||
kind: NotificationEventKind::SessionReady,
|
||||
title: "Grok".into(),
|
||||
body: "Session ready".into(),
|
||||
session_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_parses_valid_ui_notifications() {
|
||||
let raw: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[ui.notifications]
|
||||
method = "osc99"
|
||||
condition = "always"
|
||||
idle_threshold_secs = 15
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_notification_config(&raw);
|
||||
assert_eq!(config.method, NotificationMethod::Osc99);
|
||||
assert_eq!(config.condition, NotificationCondition::Always);
|
||||
assert_eq!(config.idle_threshold_secs, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_defaults_when_ui_key_missing() {
|
||||
let raw: toml::Value = toml::from_str("[other]\nkey = 1\n").unwrap();
|
||||
assert_eq!(
|
||||
load_notification_config(&raw),
|
||||
NotificationConfig::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_defaults_when_notifications_key_missing() {
|
||||
let raw: toml::Value = toml::from_str("[ui]\ntheme = \"dark\"\n").unwrap();
|
||||
assert_eq!(
|
||||
load_notification_config(&raw),
|
||||
NotificationConfig::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_defaults_for_malformed_notifications() {
|
||||
let raw: toml::Value = toml::from_str("[ui]\nnotifications = \"not-a-table\"\n").unwrap();
|
||||
assert_eq!(
|
||||
load_notification_config(&raw),
|
||||
NotificationConfig::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_defaults_for_empty_config() {
|
||||
let raw: toml::Value = toml::from_str("").unwrap();
|
||||
assert_eq!(
|
||||
load_notification_config(&raw),
|
||||
NotificationConfig::default()
|
||||
);
|
||||
}
|
||||
|
||||
// --- Progress bar (OSC 9;4) tests ---
|
||||
|
||||
fn make_title_state(is_busy: bool) -> title::TitleState<'static> {
|
||||
title::TitleState {
|
||||
session_name: None,
|
||||
model: None,
|
||||
activity: None,
|
||||
has_pending_permissions: false,
|
||||
cwd: None,
|
||||
turn_elapsed: None,
|
||||
is_busy,
|
||||
focused: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_activates_when_busy_without_activity() {
|
||||
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
||||
progress_bar: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(!svc.is_progress_active());
|
||||
svc.on_tick(&make_title_state(true));
|
||||
assert!(svc.is_progress_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_clears_when_idle_after_busy() {
|
||||
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
||||
progress_bar: true,
|
||||
..Default::default()
|
||||
});
|
||||
svc.on_tick(&make_title_state(true));
|
||||
assert!(svc.is_progress_active());
|
||||
|
||||
svc.on_tick(&make_title_state(false));
|
||||
assert!(!svc.is_progress_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_deduplicates_repeated_busy_ticks() {
|
||||
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
||||
progress_bar: true,
|
||||
..Default::default()
|
||||
});
|
||||
// Multiple busy ticks should not change the flag after the first.
|
||||
svc.on_tick(&make_title_state(true));
|
||||
assert!(svc.is_progress_active());
|
||||
svc.on_tick(&make_title_state(true));
|
||||
assert!(svc.is_progress_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_deduplicates_repeated_idle_ticks() {
|
||||
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
||||
progress_bar: true,
|
||||
..Default::default()
|
||||
});
|
||||
// Multiple idle ticks: progress_active stays false.
|
||||
svc.on_tick(&make_title_state(false));
|
||||
assert!(!svc.is_progress_active());
|
||||
svc.on_tick(&make_title_state(false));
|
||||
assert!(!svc.is_progress_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_keepalive_re_emits_after_interval() {
|
||||
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
||||
progress_bar: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Activate the progress bar.
|
||||
let _result = svc.on_tick(&make_title_state(true));
|
||||
assert!(svc.is_progress_active());
|
||||
// First tick should produce output (the indeterminate sequence).
|
||||
// (build_progress_escape returns None for unsupported brands, but
|
||||
// the flag still flips — the test verifies the timing logic.)
|
||||
let first_sent = svc.progress_last_sent;
|
||||
assert!(first_sent.is_some());
|
||||
|
||||
// Immediately following ticks should NOT refresh (interval not elapsed).
|
||||
let _result = svc.on_tick(&make_title_state(true));
|
||||
assert_eq!(svc.progress_last_sent, first_sent);
|
||||
|
||||
// Simulate the keep-alive interval elapsing.
|
||||
svc.progress_last_sent =
|
||||
Some(Instant::now() - PROGRESS_KEEPALIVE - std::time::Duration::from_millis(1));
|
||||
svc.on_tick(&make_title_state(true));
|
||||
// After the interval, progress_last_sent should have been refreshed.
|
||||
assert!(svc.progress_last_sent.unwrap() > first_sent.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_keepalive_clears_timestamp_on_idle() {
|
||||
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
||||
progress_bar: true,
|
||||
..Default::default()
|
||||
});
|
||||
svc.on_tick(&make_title_state(true));
|
||||
assert!(svc.progress_last_sent.is_some());
|
||||
|
||||
svc.on_tick(&make_title_state(false));
|
||||
assert!(!svc.is_progress_active());
|
||||
assert!(svc.progress_last_sent.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_disabled_when_config_off() {
|
||||
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
||||
progress_bar: false,
|
||||
..Default::default()
|
||||
});
|
||||
svc.on_tick(&make_title_state(true));
|
||||
assert!(!svc.is_progress_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_clears_active_progress() {
|
||||
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
||||
progress_bar: true,
|
||||
..Default::default()
|
||||
});
|
||||
svc.on_tick(&make_title_state(true));
|
||||
assert!(svc.is_progress_active());
|
||||
|
||||
svc.shutdown();
|
||||
assert!(!svc.is_progress_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_idle_state_clears_progress_and_title() {
|
||||
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
||||
progress_bar: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Activate progress bar.
|
||||
svc.on_tick(&make_title_state(true));
|
||||
assert!(svc.is_progress_active());
|
||||
assert!(svc.progress_last_sent.is_some());
|
||||
|
||||
// Flushing with is_busy=false should clear both.
|
||||
svc.flush_idle_state(&make_title_state(false));
|
||||
assert!(!svc.is_progress_active());
|
||||
assert!(svc.progress_last_sent.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_idle_state_noop_when_already_idle() {
|
||||
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
||||
progress_bar: true,
|
||||
..Default::default()
|
||||
});
|
||||
// Never activated — flush should not panic or change state.
|
||||
svc.flush_idle_state(&make_title_state(false));
|
||||
assert!(!svc.is_progress_active());
|
||||
}
|
||||
|
||||
// --- Permission notification rate-limiting tests ---
|
||||
|
||||
#[test]
|
||||
fn permission_suppression_lifecycle() {
|
||||
let mut svc = NotificationService::new_for_test(NotificationConfig::default());
|
||||
|
||||
// Initially not suppressed — first permission should fire.
|
||||
assert!(!svc.should_suppress_permission_notification());
|
||||
|
||||
// After marking, subsequent notifications are suppressed.
|
||||
svc.mark_permission_notified();
|
||||
assert!(svc.should_suppress_permission_notification());
|
||||
|
||||
// Clearing (queue drained) allows the next batch to fire.
|
||||
svc.clear_permission_notification();
|
||||
assert!(!svc.should_suppress_permission_notification());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_with_suppression_still_allows_non_permission_events() {
|
||||
// Even when permission notifications are suppressed, other event
|
||||
// kinds (e.g. TurnComplete) must still fire through notify().
|
||||
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
||||
events: vec![
|
||||
NotificationEventKind::TurnComplete,
|
||||
NotificationEventKind::ApprovalRequired,
|
||||
],
|
||||
condition: NotificationCondition::Always,
|
||||
..Default::default()
|
||||
});
|
||||
svc.mark_permission_notified();
|
||||
|
||||
// TurnComplete should not panic — suppression is only a flag the
|
||||
// *caller* checks before calling notify(), not enforced inside
|
||||
// notify() itself. This verifies the protocol=None path doesn't
|
||||
// crash regardless of suppression state.
|
||||
svc.notify(NotificationEvent {
|
||||
kind: NotificationEventKind::TurnComplete,
|
||||
title: "Grok".into(),
|
||||
body: "Done".into(),
|
||||
session_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
use std::io::Write;
|
||||
|
||||
use crate::notifications::tmux;
|
||||
use crate::terminal::{TerminalContext, TerminalName};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProgressState {
|
||||
Indeterminate,
|
||||
Clear,
|
||||
}
|
||||
|
||||
pub fn supports_progress_bar(ctx: &TerminalContext) -> bool {
|
||||
match ctx.brand {
|
||||
TerminalName::Ghostty | TerminalName::WezTerm => true,
|
||||
// iTerm2 added OSC 9;4 progress support in 3.6. Older versions
|
||||
// misinterpret the sequence as an OSC 9 desktop notification,
|
||||
// displaying the raw parameters (e.g. "4;1;-1") as alert text.
|
||||
TerminalName::Iterm2 => ctx.is_term_program_version_or_later(3, 6),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
const OSC_INDETERMINATE: &str = "\x1b]9;4;1;-1\x07";
|
||||
pub(crate) const OSC_CLEAR: &str = "\x1b]9;4;0;0\x07";
|
||||
|
||||
/// Build the progress bar escape sequence as an owned `String`.
|
||||
///
|
||||
/// Returns `None` if the terminal brand does not support the OSC 9;4 progress
|
||||
/// indicator.
|
||||
fn progress_sequence(state: ProgressState, ctx: &TerminalContext) -> Option<String> {
|
||||
if !supports_progress_bar(ctx) {
|
||||
return None;
|
||||
}
|
||||
let sequence = match state {
|
||||
ProgressState::Indeterminate => OSC_INDETERMINATE,
|
||||
ProgressState::Clear => OSC_CLEAR,
|
||||
};
|
||||
if tmux::passthrough_available(ctx) {
|
||||
Some(tmux::tmux_passthrough(sequence))
|
||||
} else {
|
||||
Some(sequence.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit_progress(state: ProgressState, ctx: &TerminalContext) {
|
||||
if let Some(seq) = progress_sequence(state, ctx) {
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
let _ = stderr.write_all(seq.as_bytes());
|
||||
let _ = stderr.flush();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the progress bar escape sequence as a `String` without writing it.
|
||||
///
|
||||
/// Returns `None` if the terminal brand does not support the OSC 9;4 progress
|
||||
/// indicator.
|
||||
pub fn build_progress_escape(state: ProgressState, ctx: &TerminalContext) -> Option<String> {
|
||||
progress_sequence(state, ctx)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal::{MultiplexerKind, TerminalContext};
|
||||
|
||||
fn ctx_for(brand: TerminalName) -> TerminalContext {
|
||||
TerminalContext {
|
||||
brand,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_brands() {
|
||||
assert!(supports_progress_bar(&ctx_for(TerminalName::Ghostty)));
|
||||
assert!(supports_progress_bar(&ctx_for(TerminalName::WezTerm)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_brands() {
|
||||
let unsupported = [
|
||||
TerminalName::Kitty,
|
||||
TerminalName::Alacritty,
|
||||
TerminalName::AppleTerminal,
|
||||
TerminalName::VsCode,
|
||||
TerminalName::WarpTerminal,
|
||||
TerminalName::GrokDesktop,
|
||||
TerminalName::Vte,
|
||||
TerminalName::Unknown,
|
||||
];
|
||||
for brand in unsupported {
|
||||
assert!(
|
||||
!supports_progress_bar(&ctx_for(brand)),
|
||||
"{brand:?} should not support progress bar"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_noop_for_unsupported_terminal() {
|
||||
let ctx = ctx_for(TerminalName::Kitty);
|
||||
// Should not panic or write anything meaningful.
|
||||
emit_progress(ProgressState::Indeterminate, &ctx);
|
||||
emit_progress(ProgressState::Clear, &ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_does_not_panic_for_supported_terminals() {
|
||||
for ctx in [
|
||||
TerminalContext {
|
||||
brand: TerminalName::Iterm2,
|
||||
term_program_version: Some("3.6.0".into()),
|
||||
..Default::default()
|
||||
},
|
||||
ctx_for(TerminalName::Ghostty),
|
||||
ctx_for(TerminalName::WezTerm),
|
||||
] {
|
||||
emit_progress(ProgressState::Indeterminate, &ctx);
|
||||
emit_progress(ProgressState::Clear, &ctx);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_with_tmux_passthrough() {
|
||||
let ctx = TerminalContext {
|
||||
brand: TerminalName::Iterm2,
|
||||
multiplexer: MultiplexerKind::Tmux,
|
||||
tmux_version: Some("tmux 3.3".into()),
|
||||
term_program_version: Some("3.6.0".into()),
|
||||
..Default::default()
|
||||
};
|
||||
// Should not panic; tmux passthrough wrapping is exercised.
|
||||
emit_progress(ProgressState::Indeterminate, &ctx);
|
||||
emit_progress(ProgressState::Clear, &ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_passthrough_wraps_indeterminate_sequence() {
|
||||
use crate::notifications::tmux::tmux_passthrough;
|
||||
let wrapped = tmux_passthrough(super::OSC_INDETERMINATE);
|
||||
assert_eq!(
|
||||
wrapped, "\x1bPtmux;\x1b\x1b]9;4;1;-1\x07\x1b\\",
|
||||
"indeterminate sequence should be wrapped with ESC bytes doubled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_passthrough_wraps_clear_sequence() {
|
||||
use crate::notifications::tmux::tmux_passthrough;
|
||||
let wrapped = tmux_passthrough(super::OSC_CLEAR);
|
||||
assert_eq!(
|
||||
wrapped, "\x1bPtmux;\x1b\x1b]9;4;0;0\x07\x1b\\",
|
||||
"clear sequence should be wrapped with ESC bytes doubled"
|
||||
);
|
||||
}
|
||||
|
||||
// --- build_progress_escape tests ---
|
||||
|
||||
#[test]
|
||||
fn build_returns_none_for_unsupported_brand() {
|
||||
let ctx = ctx_for(TerminalName::Kitty);
|
||||
assert!(build_progress_escape(ProgressState::Indeterminate, &ctx).is_none());
|
||||
assert!(build_progress_escape(ProgressState::Clear, &ctx).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_returns_indeterminate_for_new_iterm2() {
|
||||
let ctx = TerminalContext {
|
||||
brand: TerminalName::Iterm2,
|
||||
term_program_version: Some("3.6.0".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
build_progress_escape(ProgressState::Indeterminate, &ctx).as_deref(),
|
||||
Some(super::OSC_INDETERMINATE),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_returns_clear_for_supported_brand() {
|
||||
let ctx = ctx_for(TerminalName::Ghostty);
|
||||
assert_eq!(
|
||||
build_progress_escape(ProgressState::Clear, &ctx).as_deref(),
|
||||
Some(super::OSC_CLEAR),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_wraps_with_tmux_passthrough() {
|
||||
let ctx = TerminalContext {
|
||||
brand: TerminalName::Iterm2,
|
||||
multiplexer: MultiplexerKind::Tmux,
|
||||
tmux_version: Some("tmux 3.3".into()),
|
||||
term_program_version: Some("3.6.0".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = build_progress_escape(ProgressState::Indeterminate, &ctx).unwrap();
|
||||
assert!(
|
||||
result.starts_with("\x1bPtmux;"),
|
||||
"expected tmux passthrough wrapper, got: {result:?}",
|
||||
);
|
||||
assert!(
|
||||
result.ends_with("\x1b\\"),
|
||||
"expected ST terminator, got: {result:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
use std::borrow::Cow;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::notifications::tmux;
|
||||
use crate::terminal::{MultiplexerKind, TerminalContext, TerminalName};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotificationProtocol {
|
||||
/// iTerm2/WezTerm/Warp: `\x1b]9;{message}\x07`
|
||||
Osc9,
|
||||
/// Kitty: `\x1b]99;i=grok;{message}\x1b\\`
|
||||
Osc99,
|
||||
/// Ghostty/VTE: `\x1b]777;notify;{title};{body}\x1b\\`
|
||||
Osc777,
|
||||
/// Universal fallback: `\x07`
|
||||
Bel,
|
||||
/// No notification capability
|
||||
None,
|
||||
}
|
||||
|
||||
impl NotificationProtocol {
|
||||
/// Stable lowercase name for telemetry and analytics output.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Osc9 => "osc9",
|
||||
Self::Osc99 => "osc99",
|
||||
Self::Osc777 => "osc777",
|
||||
Self::Bel => "bel",
|
||||
Self::None => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose the best notification protocol for the current terminal environment.
|
||||
pub fn select_protocol(ctx: &TerminalContext) -> NotificationProtocol {
|
||||
if ctx.multiplexer == MultiplexerKind::Zellij {
|
||||
return NotificationProtocol::Bel;
|
||||
}
|
||||
match ctx.brand {
|
||||
TerminalName::Iterm2 | TerminalName::WezTerm | TerminalName::WarpTerminal => {
|
||||
NotificationProtocol::Osc9
|
||||
}
|
||||
TerminalName::Kitty => NotificationProtocol::Osc99,
|
||||
TerminalName::Ghostty
|
||||
| TerminalName::Vte
|
||||
| TerminalName::Terminator
|
||||
| TerminalName::Foot => NotificationProtocol::Osc777,
|
||||
TerminalName::GrokDesktop => NotificationProtocol::None,
|
||||
TerminalName::AppleTerminal
|
||||
| TerminalName::Alacritty
|
||||
| TerminalName::Rio
|
||||
| TerminalName::VsCode
|
||||
| TerminalName::WindowsTerminal
|
||||
| TerminalName::JetBrains
|
||||
| TerminalName::Cursor
|
||||
| TerminalName::Windsurf
|
||||
| TerminalName::Zed
|
||||
| TerminalName::Otty
|
||||
| TerminalName::Unknown => NotificationProtocol::Bel,
|
||||
}
|
||||
}
|
||||
|
||||
const BEL_BYTE: &[u8] = b"\x07";
|
||||
|
||||
/// Build the escape sequence for a notification, then write it to stderr.
|
||||
///
|
||||
/// When running under tmux the sequence is wrapped in DCS passthrough so the
|
||||
/// outer terminal sees it.
|
||||
pub fn emit_notification(
|
||||
protocol: NotificationProtocol,
|
||||
title: &str,
|
||||
body: &str,
|
||||
ctx: &TerminalContext,
|
||||
) {
|
||||
// For body-only protocols (OSC 9, OSC 99), fold the title (session
|
||||
// name) into the body so it's visible. For OSC 777 (Ghostty), the
|
||||
// tab title already appears as the notification subtitle, so we use
|
||||
// the app name to avoid showing the session name twice.
|
||||
let sequence: Cow<'_, str> = match protocol {
|
||||
NotificationProtocol::Osc9 => format!("\x1b]9;{body} \u{b7} {title}\x07").into(),
|
||||
NotificationProtocol::Osc99 => format!("\x1b]99;i=grok;{body} \u{b7} {title}\x1b\\").into(),
|
||||
NotificationProtocol::Osc777 => format!("\x1b]777;notify;Grok;{body}\x1b\\").into(),
|
||||
NotificationProtocol::Bel => Cow::Borrowed("\x07"),
|
||||
NotificationProtocol::None => return,
|
||||
};
|
||||
|
||||
if ctx.is_tmux_backed() {
|
||||
let wrapped = tmux::tmux_passthrough(&sequence);
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
let _ = stderr.write_all(wrapped.as_bytes());
|
||||
let _ = stderr.flush();
|
||||
});
|
||||
} else if matches!(protocol, NotificationProtocol::Bel) {
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
let _ = stderr.write_all(BEL_BYTE);
|
||||
let _ = stderr.flush();
|
||||
});
|
||||
} else {
|
||||
let bytes = sequence.as_bytes();
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
let _ = stderr.write_all(bytes);
|
||||
let _ = stderr.flush();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal::{MultiplexerKind, TerminalContext, TerminalName};
|
||||
|
||||
fn ctx_with_brand(brand: TerminalName) -> TerminalContext {
|
||||
TerminalContext {
|
||||
brand,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx_with_brand_and_mux(brand: TerminalName, mux: MultiplexerKind) -> TerminalContext {
|
||||
TerminalContext {
|
||||
brand,
|
||||
multiplexer: mux,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// --- select_protocol: every TerminalName variant ---
|
||||
|
||||
#[test]
|
||||
fn select_iterm2_uses_osc9() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand(TerminalName::Iterm2)),
|
||||
NotificationProtocol::Osc9
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_wezterm_uses_osc9() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand(TerminalName::WezTerm)),
|
||||
NotificationProtocol::Osc9
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_warp_uses_osc9() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand(TerminalName::WarpTerminal)),
|
||||
NotificationProtocol::Osc9
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_kitty_uses_osc99() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand(TerminalName::Kitty)),
|
||||
NotificationProtocol::Osc99
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_ghostty_uses_osc777() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand(TerminalName::Ghostty)),
|
||||
NotificationProtocol::Osc777
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_vte_uses_osc777() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand(TerminalName::Vte)),
|
||||
NotificationProtocol::Osc777
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_grok_desktop_uses_none() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand(TerminalName::GrokDesktop)),
|
||||
NotificationProtocol::None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_apple_terminal_uses_bel() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand(TerminalName::AppleTerminal)),
|
||||
NotificationProtocol::Bel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_alacritty_uses_bel() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand(TerminalName::Alacritty)),
|
||||
NotificationProtocol::Bel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_vscode_uses_bel() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand(TerminalName::VsCode)),
|
||||
NotificationProtocol::Bel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_unknown_uses_bel() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand(TerminalName::Unknown)),
|
||||
NotificationProtocol::Bel
|
||||
);
|
||||
}
|
||||
|
||||
// --- Zellij override ---
|
||||
|
||||
#[test]
|
||||
fn zellij_overrides_to_bel_for_kitty() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand_and_mux(
|
||||
TerminalName::Kitty,
|
||||
MultiplexerKind::Zellij
|
||||
)),
|
||||
NotificationProtocol::Bel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zellij_overrides_to_bel_for_ghostty() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand_and_mux(
|
||||
TerminalName::Ghostty,
|
||||
MultiplexerKind::Zellij
|
||||
)),
|
||||
NotificationProtocol::Bel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zellij_overrides_to_bel_for_iterm2() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand_and_mux(
|
||||
TerminalName::Iterm2,
|
||||
MultiplexerKind::Zellij
|
||||
)),
|
||||
NotificationProtocol::Bel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zellij_overrides_to_bel_for_wezterm() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand_and_mux(
|
||||
TerminalName::WezTerm,
|
||||
MultiplexerKind::Zellij
|
||||
)),
|
||||
NotificationProtocol::Bel
|
||||
);
|
||||
}
|
||||
|
||||
// --- tmux does NOT override protocol selection (passthrough is handled
|
||||
// at emission time, not selection time) ---
|
||||
|
||||
#[test]
|
||||
fn tmux_preserves_osc9_for_iterm2() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand_and_mux(
|
||||
TerminalName::Iterm2,
|
||||
MultiplexerKind::Tmux
|
||||
)),
|
||||
NotificationProtocol::Osc9
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmux_preserves_osc99_for_kitty() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand_and_mux(
|
||||
TerminalName::Kitty,
|
||||
MultiplexerKind::Tmux
|
||||
)),
|
||||
NotificationProtocol::Osc99
|
||||
);
|
||||
}
|
||||
|
||||
// --- screen does not override ---
|
||||
|
||||
#[test]
|
||||
fn screen_preserves_osc777_for_ghostty() {
|
||||
assert_eq!(
|
||||
select_protocol(&ctx_with_brand_and_mux(
|
||||
TerminalName::Ghostty,
|
||||
MultiplexerKind::Screen
|
||||
)),
|
||||
NotificationProtocol::Osc777
|
||||
);
|
||||
}
|
||||
|
||||
// --- emit_notification: verifies None is a no-op (does not panic) ---
|
||||
|
||||
#[test]
|
||||
fn emit_none_is_noop() {
|
||||
let ctx = ctx_with_brand(TerminalName::GrokDesktop);
|
||||
// Should return immediately without writing anything.
|
||||
emit_notification(NotificationProtocol::None, "title", "body", &ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_bel_does_not_panic() {
|
||||
let ctx = ctx_with_brand(TerminalName::Unknown);
|
||||
emit_notification(NotificationProtocol::Bel, "", "", &ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_osc9_does_not_panic() {
|
||||
let ctx = ctx_with_brand(TerminalName::Iterm2);
|
||||
emit_notification(NotificationProtocol::Osc9, "title", "body", &ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_osc99_does_not_panic() {
|
||||
let ctx = ctx_with_brand(TerminalName::Kitty);
|
||||
emit_notification(NotificationProtocol::Osc99, "title", "body", &ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_osc777_does_not_panic() {
|
||||
let ctx = ctx_with_brand(TerminalName::Ghostty);
|
||||
emit_notification(NotificationProtocol::Osc777, "title", "body", &ctx);
|
||||
}
|
||||
|
||||
// --- exhaustive brand coverage in a table-driven test ---
|
||||
|
||||
#[test]
|
||||
fn all_brands_have_defined_protocol() {
|
||||
let cases: &[(TerminalName, NotificationProtocol)] = &[
|
||||
(TerminalName::Iterm2, NotificationProtocol::Osc9),
|
||||
(TerminalName::WezTerm, NotificationProtocol::Osc9),
|
||||
(TerminalName::WarpTerminal, NotificationProtocol::Osc9),
|
||||
(TerminalName::Kitty, NotificationProtocol::Osc99),
|
||||
(TerminalName::Ghostty, NotificationProtocol::Osc777),
|
||||
(TerminalName::Vte, NotificationProtocol::Osc777),
|
||||
(TerminalName::Foot, NotificationProtocol::Osc777),
|
||||
(TerminalName::GrokDesktop, NotificationProtocol::None),
|
||||
(TerminalName::AppleTerminal, NotificationProtocol::Bel),
|
||||
(TerminalName::Alacritty, NotificationProtocol::Bel),
|
||||
(TerminalName::VsCode, NotificationProtocol::Bel),
|
||||
(TerminalName::WindowsTerminal, NotificationProtocol::Bel),
|
||||
(TerminalName::Unknown, NotificationProtocol::Bel),
|
||||
];
|
||||
|
||||
for &(brand, expected) in cases {
|
||||
let ctx = ctx_with_brand(brand);
|
||||
assert_eq!(
|
||||
select_protocol(&ctx),
|
||||
expected,
|
||||
"protocol mismatch for {brand:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zellij_forces_bel_for_all_osc_brands() {
|
||||
let osc_brands = [
|
||||
TerminalName::Iterm2,
|
||||
TerminalName::WezTerm,
|
||||
TerminalName::WarpTerminal,
|
||||
TerminalName::Kitty,
|
||||
TerminalName::Ghostty,
|
||||
TerminalName::Vte,
|
||||
];
|
||||
for brand in osc_brands {
|
||||
let ctx = ctx_with_brand_and_mux(brand, MultiplexerKind::Zellij);
|
||||
assert_eq!(
|
||||
select_protocol(&ctx),
|
||||
NotificationProtocol::Bel,
|
||||
"zellij should force BEL for {brand:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Platform-specific sleep prevention.
|
||||
//!
|
||||
//! Prevents the machine from idle-sleeping while an agent turn is in progress.
|
||||
//! macOS uses IOKit power assertions; Linux spawns `systemd-inhibit`.
|
||||
//!
|
||||
//! Threading: lives on `AppView` (single-threaded, `!Send`).
|
||||
//! Uses `Cell`/`RefCell` instead of atomics/mutexes.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
/// Prevents idle sleep while an agent turn is running.
|
||||
///
|
||||
/// Calls are idempotent: repeated `inhibit()` or `release()` calls are no-ops
|
||||
/// when already in the requested state. On `Drop`, any held assertion is released.
|
||||
pub struct SleepInhibitor {
|
||||
#[cfg(target_os = "macos")]
|
||||
assertion_id: Cell<Option<u32>>,
|
||||
#[cfg(target_os = "linux")]
|
||||
child: std::cell::RefCell<Option<std::process::Child>>,
|
||||
active: Cell<bool>,
|
||||
/// Set on first `platform_inhibit` failure to avoid repeated spawn
|
||||
/// attempts on platforms where the inhibitor is unavailable (e.g.
|
||||
/// containers without systemd-inhibit).
|
||||
platform_unavailable: Cell<bool>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl SleepInhibitor {
|
||||
pub fn new(enabled: bool) -> Self {
|
||||
Self {
|
||||
#[cfg(target_os = "macos")]
|
||||
assertion_id: Cell::new(None),
|
||||
#[cfg(target_os = "linux")]
|
||||
child: std::cell::RefCell::new(None),
|
||||
active: Cell::new(false),
|
||||
platform_unavailable: Cell::new(false),
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Prevent idle sleep. No-op if already inhibiting, disabled, or
|
||||
/// platform support was already determined to be unavailable.
|
||||
pub fn inhibit(&self) {
|
||||
if !self.enabled || self.active.get() || self.platform_unavailable.get() {
|
||||
return;
|
||||
}
|
||||
if self.platform_inhibit() {
|
||||
self.active.set(true);
|
||||
} else {
|
||||
self.platform_unavailable.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow idle sleep again. No-op if not currently inhibiting.
|
||||
pub fn release(&self) {
|
||||
if !self.active.get() {
|
||||
return;
|
||||
}
|
||||
self.platform_release();
|
||||
self.active.set(false);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn platform_inhibit(&self) -> bool {
|
||||
let mut assertion_id: u32 = 0;
|
||||
let reason = core_foundation::string::CFString::new("grok: agent turn in progress");
|
||||
let assertion_type =
|
||||
core_foundation::string::CFString::from_static_string("NoIdleSleepAssertion");
|
||||
|
||||
// IOPMAssertionCreateWithName returns kIOReturnSuccess (0) on success.
|
||||
let result = unsafe {
|
||||
IOPMAssertionCreateWithName(
|
||||
assertion_type.as_concrete_TypeRef(),
|
||||
255, // kIOPMAssertionLevelOn
|
||||
reason.as_concrete_TypeRef(),
|
||||
&mut assertion_id,
|
||||
)
|
||||
};
|
||||
|
||||
if result == 0 {
|
||||
self.assertion_id.set(Some(assertion_id));
|
||||
true
|
||||
} else {
|
||||
tracing::warn!(error_code = result, "failed to create IOPMAssertion");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn platform_release(&self) {
|
||||
if let Some(id) = self.assertion_id.get() {
|
||||
let result = unsafe { IOPMAssertionRelease(id) };
|
||||
if result != 0 {
|
||||
tracing::warn!(error_code = result, "failed to release IOPMAssertion");
|
||||
}
|
||||
self.assertion_id.set(None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform_inhibit(&self) -> bool {
|
||||
let mut cmd = std::process::Command::new("systemd-inhibit");
|
||||
cmd.args([
|
||||
"--what=idle",
|
||||
"--who=grok",
|
||||
"--why=agent turn in progress",
|
||||
"sleep",
|
||||
"infinity",
|
||||
])
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
kigi_tty_utils::detach_std_command(&mut cmd);
|
||||
let result = cmd.spawn();
|
||||
|
||||
match result {
|
||||
Ok(child) => {
|
||||
*self.child.borrow_mut() = Some(child);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "systemd-inhibit not available");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform_release(&self) {
|
||||
if let Some(mut child) = self.child.borrow_mut().take() {
|
||||
let _ = nix::sys::signal::kill(
|
||||
nix::unistd::Pid::from_raw(child.id() as i32),
|
||||
nix::sys::signal::Signal::SIGTERM,
|
||||
);
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
fn platform_inhibit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
fn platform_release(&self) {}
|
||||
}
|
||||
|
||||
impl Drop for SleepInhibitor {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
}
|
||||
}
|
||||
|
||||
// -- macOS IOKit FFI ---------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use core_foundation::base::TCFType;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[link(name = "IOKit", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn IOPMAssertionCreateWithName(
|
||||
assertion_type: core_foundation::string::CFStringRef,
|
||||
assertion_level: u32,
|
||||
reason_for_activity: core_foundation::string::CFStringRef,
|
||||
assertion_id: *mut u32,
|
||||
) -> i32;
|
||||
|
||||
fn IOPMAssertionRelease(assertion_id: u32) -> i32;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn disabled_inhibitor_is_noop() {
|
||||
let inhibitor = SleepInhibitor::new(false);
|
||||
inhibitor.inhibit();
|
||||
assert!(!inhibitor.active.get());
|
||||
inhibitor.release();
|
||||
assert!(!inhibitor.active.get());
|
||||
}
|
||||
|
||||
/// On unsupported platforms (not macOS/Linux), platform_inhibit returns
|
||||
/// false and `platform_unavailable` latches to prevent retry spam.
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
#[test]
|
||||
fn platform_unavailable_prevents_retries() {
|
||||
let inhibitor = SleepInhibitor::new(true);
|
||||
// First attempt: platform_inhibit fails, flag latches.
|
||||
inhibitor.inhibit();
|
||||
assert!(!inhibitor.active.get());
|
||||
assert!(inhibitor.platform_unavailable.get());
|
||||
// Subsequent calls are short-circuited by the flag.
|
||||
inhibitor.inhibit();
|
||||
assert!(!inhibitor.active.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inhibit_is_idempotent() {
|
||||
let inhibitor = SleepInhibitor::new(true);
|
||||
inhibitor.inhibit();
|
||||
let was_active = inhibitor.active.get();
|
||||
// Second call should not change state (and not spawn a second child/assertion).
|
||||
inhibitor.inhibit();
|
||||
assert_eq!(inhibitor.active.get(), was_active);
|
||||
inhibitor.release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_is_idempotent() {
|
||||
let inhibitor = SleepInhibitor::new(true);
|
||||
// Release without prior inhibit is a no-op.
|
||||
inhibitor.release();
|
||||
assert!(!inhibitor.active.get());
|
||||
// Double release after inhibit is safe.
|
||||
inhibitor.inhibit();
|
||||
inhibitor.release();
|
||||
assert!(!inhibitor.active.get());
|
||||
inhibitor.release();
|
||||
assert!(!inhibitor.active.get());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inhibit_release_cycle() {
|
||||
let inhibitor = SleepInhibitor::new(true);
|
||||
inhibitor.inhibit();
|
||||
// On Linux, this spawns systemd-inhibit; on other platforms it's a no-op.
|
||||
// Either way, the active flag tracks the intent.
|
||||
let was_active = inhibitor.active.get();
|
||||
inhibitor.release();
|
||||
assert!(!inhibitor.active.get());
|
||||
// Can re-inhibit after release.
|
||||
inhibitor.inhibit();
|
||||
assert_eq!(inhibitor.active.get(), was_active);
|
||||
inhibitor.release();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_releases() {
|
||||
let inhibitor = SleepInhibitor::new(true);
|
||||
inhibitor.inhibit();
|
||||
drop(inhibitor);
|
||||
// No assertion — just verify no panic/leak.
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_inhibit_spawns_child() {
|
||||
let inhibitor = SleepInhibitor::new(true);
|
||||
inhibitor.inhibit();
|
||||
if inhibitor.active.get() {
|
||||
assert!(inhibitor.child.borrow().is_some());
|
||||
inhibitor.release();
|
||||
assert!(inhibitor.child.borrow().is_none());
|
||||
}
|
||||
// If systemd-inhibit isn't available, active stays false — that's fine.
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_release_kills_child() {
|
||||
let inhibitor = SleepInhibitor::new(true);
|
||||
inhibitor.inhibit();
|
||||
if inhibitor.active.get() {
|
||||
// Grab the pid before release.
|
||||
let pid = inhibitor.child.borrow().as_ref().map(|c| c.id());
|
||||
assert!(pid.is_some());
|
||||
inhibitor.release();
|
||||
assert!(inhibitor.child.borrow().is_none());
|
||||
assert!(!inhibitor.active.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
use std::fmt::Write;
|
||||
|
||||
use crossterm::terminal::SetTitle;
|
||||
|
||||
use super::config::{TitleConfig, TitleItem};
|
||||
use crate::acp::tracker::TurnActivity;
|
||||
|
||||
const TITLE_SPINNER: &[char] = &[
|
||||
'\u{280B}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283C}', '\u{2834}', '\u{2826}', '\u{2827}',
|
||||
];
|
||||
|
||||
/// Hold each spinner frame for this many ticks before advancing.
|
||||
///
|
||||
/// Terminals (notably Ghostty) debounce tab title updates, so writing a
|
||||
/// new title every tick (~33ms at 30fps) produces more OSC 0 writes than
|
||||
/// the tab bar can render. A divisor of 8 gives ~264ms per frame — slow
|
||||
/// enough for debounced renderers while still looking animated.
|
||||
const TITLE_SPINNER_DIVISOR: u64 = 8;
|
||||
|
||||
/// Hold the "⚠ Action Required" label for this many ticks before toggling
|
||||
/// (only while unfocused; see focused field below).
|
||||
///
|
||||
/// A divisor of 15 at 30fps gives ~500ms visible, ~500ms hidden — a calm 1s
|
||||
/// blink cycle that reads as intentional rather than broken flickering. When
|
||||
/// focused we show the prefix statically to eliminate oscillation during
|
||||
/// active interaction (e.g. typing in permission modals).
|
||||
const ACTION_REQUIRED_BLINK_DIVISOR: u64 = 15;
|
||||
|
||||
/// State passed into `TitleManager::update()` each tick.
|
||||
pub struct TitleState<'a> {
|
||||
pub session_name: Option<&'a str>,
|
||||
pub model: Option<&'a str>,
|
||||
pub activity: Option<&'a TurnActivity>,
|
||||
pub has_pending_permissions: bool,
|
||||
pub cwd: Option<&'a str>,
|
||||
pub turn_elapsed: Option<std::time::Duration>,
|
||||
/// Whether the agent is busy (turn or command running), even if
|
||||
/// `activity` is `None` (the "Waiting" gap before first chunk).
|
||||
pub is_busy: bool,
|
||||
/// Whether the terminal pane/window is currently focused (from
|
||||
/// FocusTracker). Suppresses title blinking/oscillation while the
|
||||
/// user is actively interacting.
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
pub struct TitleManager {
|
||||
items: Vec<TitleItem>,
|
||||
last_title: String,
|
||||
composed: String,
|
||||
spinner_frame: usize,
|
||||
tick_count: u64,
|
||||
}
|
||||
|
||||
impl TitleManager {
|
||||
pub fn new(config: &TitleConfig) -> Self {
|
||||
Self {
|
||||
items: config.items.clone(),
|
||||
last_title: String::new(),
|
||||
composed: String::new(),
|
||||
spinner_frame: 0,
|
||||
tick_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compose the title string from the current state.
|
||||
///
|
||||
/// Returns the escape sequence bytes to set the terminal title when the
|
||||
/// composed title differs from the last one emitted. Returns `None` when
|
||||
/// the title is unchanged (dedup).
|
||||
pub fn update(&mut self, state: &TitleState<'_>) -> Option<String> {
|
||||
self.composed.clear();
|
||||
let mut has_parts = false;
|
||||
|
||||
// Iterate by index: TitleItem is Copy, so indexing avoids borrowing
|
||||
// self.items while we mutate self.composed.
|
||||
for i in 0..self.items.len() {
|
||||
let item = self.items[i];
|
||||
if write_item(
|
||||
&mut self.composed,
|
||||
&mut has_parts,
|
||||
item,
|
||||
state,
|
||||
self.spinner_frame,
|
||||
self.tick_count,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if !has_parts {
|
||||
self.composed.clear();
|
||||
self.composed.push_str("grok");
|
||||
}
|
||||
|
||||
let result = if self.composed != self.last_title {
|
||||
Some(build_title_escape(&self.composed))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Swap into last_title when changed (update the dedup cache).
|
||||
if result.is_some() {
|
||||
std::mem::swap(&mut self.last_title, &mut self.composed);
|
||||
}
|
||||
|
||||
// Advance counters after rendering so the first tick sees
|
||||
// tick_count=0 (phase 0, ActionRequired visible) and spinner_frame=0.
|
||||
self.tick_count = self.tick_count.wrapping_add(1);
|
||||
self.spinner_frame =
|
||||
(self.tick_count / TITLE_SPINNER_DIVISOR) as usize % TITLE_SPINNER.len();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) -> String {
|
||||
let esc = build_title_escape("grok");
|
||||
self.last_title.clear();
|
||||
self.last_title.push_str("grok");
|
||||
self.spinner_frame = 0;
|
||||
self.tick_count = 0;
|
||||
esc
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single title item into `buf`. Returns `true` if a part was written.
|
||||
fn write_item(
|
||||
buf: &mut String,
|
||||
has_parts: &mut bool,
|
||||
item: TitleItem,
|
||||
state: &TitleState<'_>,
|
||||
spinner_frame: usize,
|
||||
tick_count: u64,
|
||||
) -> bool {
|
||||
match item {
|
||||
TitleItem::Grok => {
|
||||
push_separator(buf, has_parts);
|
||||
buf.push_str("grok");
|
||||
}
|
||||
TitleItem::Spinner => {
|
||||
if !state.is_busy && state.activity.is_none() {
|
||||
return false;
|
||||
}
|
||||
push_separator(buf, has_parts);
|
||||
buf.push(TITLE_SPINNER[spinner_frame]);
|
||||
}
|
||||
TitleItem::Activity => {
|
||||
if let Some(activity) = state.activity {
|
||||
push_separator(buf, has_parts);
|
||||
write_activity(buf, activity);
|
||||
} else if state.is_busy {
|
||||
push_separator(buf, has_parts);
|
||||
buf.push_str("Waiting");
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
TitleItem::SessionName => {
|
||||
let Some(name) = state.session_name.filter(|s| !s.is_empty()) else {
|
||||
return false;
|
||||
};
|
||||
push_separator(buf, has_parts);
|
||||
write_truncated(buf, name, 40);
|
||||
}
|
||||
TitleItem::Model => {
|
||||
let Some(model) = state.model.filter(|s| !s.is_empty()) else {
|
||||
return false;
|
||||
};
|
||||
push_separator(buf, has_parts);
|
||||
write_truncated(buf, model, 30);
|
||||
}
|
||||
TitleItem::Cwd => {
|
||||
let Some(cwd) = state.cwd else {
|
||||
return false;
|
||||
};
|
||||
let short = cwd.rsplit('/').next().unwrap_or(cwd);
|
||||
if short.is_empty() {
|
||||
return false;
|
||||
}
|
||||
push_separator(buf, has_parts);
|
||||
write_truncated(buf, short, 30);
|
||||
}
|
||||
TitleItem::TurnTimer => {
|
||||
let Some(elapsed) = state.turn_elapsed else {
|
||||
return false;
|
||||
};
|
||||
let secs = elapsed.as_secs();
|
||||
if secs < 1 {
|
||||
return false;
|
||||
}
|
||||
push_separator(buf, has_parts);
|
||||
let _ = write!(buf, "{}s", secs);
|
||||
}
|
||||
TitleItem::ActionRequired => {
|
||||
if !state.has_pending_permissions {
|
||||
return false;
|
||||
}
|
||||
// Blink (oscillate) only while unfocused, for tab attention.
|
||||
// When focused (user actively interacting, e.g. in permission
|
||||
// modal or prompt), show static prefix to stop distracting flash.
|
||||
let should_blink =
|
||||
!state.focused && !(tick_count / ACTION_REQUIRED_BLINK_DIVISOR).is_multiple_of(2);
|
||||
if should_blink {
|
||||
return false;
|
||||
}
|
||||
push_separator(buf, has_parts);
|
||||
buf.push_str("\u{26A0} Action Required");
|
||||
}
|
||||
}
|
||||
*has_parts = true;
|
||||
true
|
||||
}
|
||||
|
||||
fn push_separator(buf: &mut String, has_parts: &mut bool) {
|
||||
if *has_parts {
|
||||
buf.push_str(" - ");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_activity(buf: &mut String, activity: &TurnActivity) {
|
||||
match activity {
|
||||
TurnActivity::Thinking => buf.push_str("Thinking"),
|
||||
TurnActivity::Responding => buf.push_str("Responding"),
|
||||
TurnActivity::ToolRunning { title, description } => {
|
||||
if let Some(desc) = description
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
buf.push_str(&crate::acp::tracker::format_waiting_for_subject(desc));
|
||||
} else if title.is_empty() {
|
||||
buf.push_str("Running tool");
|
||||
} else {
|
||||
buf.push_str("Running: ");
|
||||
write_truncated(buf, title, 30);
|
||||
}
|
||||
}
|
||||
TurnActivity::AutoCompacting => buf.push_str("Compacting"),
|
||||
TurnActivity::Retrying {
|
||||
attempt,
|
||||
max_retries,
|
||||
..
|
||||
} => {
|
||||
let _ = write!(buf, "Retrying ({}/{})", attempt, max_retries);
|
||||
}
|
||||
TurnActivity::Waiting(reason) => buf.push_str(&reason.label()),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_truncated(buf: &mut String, s: &str, max: usize) {
|
||||
// Fast path: ASCII-only strings where byte length == char count.
|
||||
if s.len() <= max {
|
||||
buf.push_str(s);
|
||||
return;
|
||||
}
|
||||
// Slow path: iterate chars for multi-byte or over-limit strings.
|
||||
for (count, ch) in s.chars().enumerate() {
|
||||
if count >= max {
|
||||
buf.push('\u{2026}');
|
||||
return;
|
||||
}
|
||||
buf.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the escape sequence for setting the terminal title without writing
|
||||
/// it to stderr. The caller is responsible for routing these bytes through
|
||||
/// the frame pipeline.
|
||||
///
|
||||
/// Control characters are stripped here: title parts include remote-sourced
|
||||
/// strings (e.g. grok.com conversation titles), which must not terminate the
|
||||
/// OSC sequence early or inject escapes into the terminal.
|
||||
fn build_title_escape(title: &str) -> String {
|
||||
let sanitized: String = title.chars().filter(|c| !c.is_control()).collect();
|
||||
let mut buf = Vec::new();
|
||||
let _ = crossterm::queue!(&mut buf, SetTitle(sanitized));
|
||||
String::from_utf8(buf).expect("crossterm SetTitle produces valid UTF-8")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn default_config() -> TitleConfig {
|
||||
TitleConfig::default()
|
||||
}
|
||||
|
||||
fn config_with_items(items: Vec<TitleItem>) -> TitleConfig {
|
||||
TitleConfig {
|
||||
enabled: true,
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
fn idle_state<'a>() -> TitleState<'a> {
|
||||
TitleState {
|
||||
session_name: None,
|
||||
model: None,
|
||||
activity: None,
|
||||
has_pending_permissions: false,
|
||||
cwd: None,
|
||||
turn_elapsed: None,
|
||||
is_busy: false,
|
||||
focused: true,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Title composition tests ---
|
||||
|
||||
#[test]
|
||||
fn grok_only_produces_just_grok() {
|
||||
let cfg = config_with_items(vec![TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = idle_state();
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "grok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_name_and_grok_joined_with_separator() {
|
||||
let cfg = config_with_items(vec![TitleItem::SessionName, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = TitleState {
|
||||
session_name: Some("my project"),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "my project - grok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_session_name_skipped() {
|
||||
let cfg = config_with_items(vec![TitleItem::SessionName, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = idle_state();
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "grok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_session_name_skipped() {
|
||||
let cfg = config_with_items(vec![TitleItem::SessionName, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = TitleState {
|
||||
session_name: Some(""),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "grok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_only_shown_when_active() {
|
||||
let cfg = config_with_items(vec![TitleItem::Spinner, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
|
||||
// Idle: spinner absent
|
||||
mgr.update(&idle_state());
|
||||
assert_eq!(mgr.last_title, "grok");
|
||||
|
||||
// Active: spinner present
|
||||
let activity = TurnActivity::Thinking;
|
||||
let state = TitleState {
|
||||
activity: Some(&activity),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert!(mgr.last_title.contains(" - grok"));
|
||||
let spinner_part: String = mgr.last_title.chars().take(1).collect();
|
||||
assert!(
|
||||
TITLE_SPINNER.contains(&spinner_part.chars().next().unwrap()),
|
||||
"expected braille spinner char, got: {}",
|
||||
spinner_part
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_advances_with_divisor() {
|
||||
let cfg = config_with_items(vec![TitleItem::Spinner]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let activity = TurnActivity::Thinking;
|
||||
let state = TitleState {
|
||||
activity: Some(&activity),
|
||||
..idle_state()
|
||||
};
|
||||
|
||||
// Run through one full cycle (DIVISOR ticks per frame * frame count).
|
||||
let total = TITLE_SPINNER_DIVISOR as usize * TITLE_SPINNER.len();
|
||||
let mut frames = Vec::new();
|
||||
for _ in 0..total {
|
||||
mgr.update(&state);
|
||||
frames.push(mgr.last_title.clone());
|
||||
}
|
||||
// Across a full cycle we should see all spinner frames.
|
||||
let unique: std::collections::HashSet<_> = frames.iter().collect();
|
||||
assert_eq!(unique.len(), TITLE_SPINNER.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_holds_frame_for_divisor_ticks() {
|
||||
let cfg = config_with_items(vec![TitleItem::Spinner]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let activity = TurnActivity::Thinking;
|
||||
let state = TitleState {
|
||||
activity: Some(&activity),
|
||||
..idle_state()
|
||||
};
|
||||
|
||||
// First frame should be stable for DIVISOR ticks.
|
||||
mgr.update(&state);
|
||||
let first = mgr.last_title.clone();
|
||||
for _ in 1..TITLE_SPINNER_DIVISOR {
|
||||
mgr.update(&state);
|
||||
assert_eq!(
|
||||
mgr.last_title, first,
|
||||
"spinner should hold frame during divisor window"
|
||||
);
|
||||
}
|
||||
// After DIVISOR ticks, the frame should advance.
|
||||
mgr.update(&state);
|
||||
assert_ne!(
|
||||
mgr.last_title, first,
|
||||
"spinner should advance after divisor ticks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_wraps_around() {
|
||||
let cfg = config_with_items(vec![TitleItem::Spinner]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let activity = TurnActivity::Thinking;
|
||||
let state = TitleState {
|
||||
activity: Some(&activity),
|
||||
..idle_state()
|
||||
};
|
||||
|
||||
// Run through more than one full cycle.
|
||||
mgr.update(&state);
|
||||
let first = mgr.last_title.clone();
|
||||
let total = TITLE_SPINNER_DIVISOR as usize * TITLE_SPINNER.len();
|
||||
for _ in 1..total {
|
||||
mgr.update(&state);
|
||||
}
|
||||
// After a full cycle, the frame should wrap back to the first char.
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_label_thinking() {
|
||||
let cfg = config_with_items(vec![TitleItem::Activity]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let activity = TurnActivity::Thinking;
|
||||
let state = TitleState {
|
||||
activity: Some(&activity),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "Thinking");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_label_responding() {
|
||||
let cfg = config_with_items(vec![TitleItem::Activity]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let activity = TurnActivity::Responding;
|
||||
let state = TitleState {
|
||||
activity: Some(&activity),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "Responding");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_label_tool_running_with_title() {
|
||||
let cfg = config_with_items(vec![TitleItem::Activity]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let activity = TurnActivity::ToolRunning {
|
||||
title: "cargo build".to_owned(),
|
||||
description: None,
|
||||
};
|
||||
let state = TitleState {
|
||||
activity: Some(&activity),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "Running: cargo build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_label_tool_running_empty_title() {
|
||||
let cfg = config_with_items(vec![TitleItem::Activity]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let activity = TurnActivity::ToolRunning {
|
||||
title: String::new(),
|
||||
description: None,
|
||||
};
|
||||
let state = TitleState {
|
||||
activity: Some(&activity),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "Running tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_label_retrying() {
|
||||
let cfg = config_with_items(vec![TitleItem::Activity]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let activity = TurnActivity::Retrying {
|
||||
attempt: 2,
|
||||
max_retries: 5,
|
||||
reason: "timeout".to_owned(),
|
||||
};
|
||||
let state = TitleState {
|
||||
activity: Some(&activity),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "Retrying (2/5)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_hidden_when_idle() {
|
||||
let cfg = config_with_items(vec![TitleItem::Activity, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
mgr.update(&idle_state());
|
||||
assert_eq!(mgr.last_title, "grok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_shown_when_busy_without_activity() {
|
||||
let cfg = config_with_items(vec![TitleItem::Spinner, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = TitleState {
|
||||
is_busy: true,
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert!(mgr.last_title.contains(" - grok"));
|
||||
let spinner_part: String = mgr.last_title.chars().take(1).collect();
|
||||
assert!(
|
||||
TITLE_SPINNER.contains(&spinner_part.chars().next().unwrap()),
|
||||
"expected braille spinner char during Waiting, got: {}",
|
||||
spinner_part
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_shows_waiting_when_busy_without_activity() {
|
||||
let cfg = config_with_items(vec![TitleItem::Activity, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = TitleState {
|
||||
is_busy: true,
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "Waiting - grok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_prefers_specific_activity_over_waiting() {
|
||||
let cfg = config_with_items(vec![TitleItem::Activity, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let activity = TurnActivity::Thinking;
|
||||
let state = TitleState {
|
||||
activity: Some(&activity),
|
||||
is_busy: true,
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "Thinking - grok");
|
||||
}
|
||||
|
||||
// --- Action Required blinking ---
|
||||
|
||||
#[test]
|
||||
fn action_required_visible_on_first_tick() {
|
||||
let cfg = config_with_items(vec![TitleItem::ActionRequired, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = TitleState {
|
||||
has_pending_permissions: true,
|
||||
..idle_state()
|
||||
};
|
||||
|
||||
// tick_count=0 (even) on first render → ActionRequired visible.
|
||||
mgr.update(&state);
|
||||
assert!(
|
||||
mgr.last_title.contains("Action Required"),
|
||||
"first tick should show ActionRequired, got: {}",
|
||||
mgr.last_title
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_required_blinks_across_ticks() {
|
||||
let cfg = config_with_items(vec![TitleItem::ActionRequired, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = TitleState {
|
||||
has_pending_permissions: true,
|
||||
focused: false, // unfocused → should blink
|
||||
..idle_state()
|
||||
};
|
||||
|
||||
// First tick: tick_count=0, phase=0 (visible).
|
||||
mgr.update(&state);
|
||||
let t1 = mgr.last_title.clone();
|
||||
|
||||
// Title stays stable for the rest of the visible phase.
|
||||
for _ in 1..ACTION_REQUIRED_BLINK_DIVISOR {
|
||||
mgr.update(&state);
|
||||
assert_eq!(
|
||||
mgr.last_title, t1,
|
||||
"title should stay stable within a blink phase"
|
||||
);
|
||||
}
|
||||
|
||||
// Crossing into the hidden phase.
|
||||
mgr.update(&state);
|
||||
let t2 = mgr.last_title.clone();
|
||||
|
||||
assert_ne!(t1, t2);
|
||||
assert!(t1.contains("Action Required"));
|
||||
assert!(!t2.contains("Action Required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_required_hidden_when_no_permissions() {
|
||||
let cfg = config_with_items(vec![TitleItem::ActionRequired, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = TitleState {
|
||||
has_pending_permissions: false,
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "grok");
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "grok");
|
||||
}
|
||||
|
||||
// --- Dedup (no-op when unchanged) ---
|
||||
|
||||
#[test]
|
||||
fn dedup_skips_emission_when_unchanged() {
|
||||
let cfg = config_with_items(vec![TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = idle_state();
|
||||
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "grok");
|
||||
|
||||
// Second update: title is identical, last_title stays the same (no re-emit).
|
||||
let title_before = mgr.last_title.clone();
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, title_before);
|
||||
}
|
||||
|
||||
// --- Empty items list ---
|
||||
|
||||
#[test]
|
||||
fn empty_items_produces_grok_fallback() {
|
||||
let cfg = config_with_items(vec![]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
mgr.update(&idle_state());
|
||||
assert_eq!(mgr.last_title, "grok");
|
||||
}
|
||||
|
||||
// --- Model item ---
|
||||
|
||||
#[test]
|
||||
fn model_item_shown_when_present() {
|
||||
let cfg = config_with_items(vec![TitleItem::Model, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = TitleState {
|
||||
model: Some("grok-3"),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "grok-3 - grok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_item_hidden_when_none() {
|
||||
let cfg = config_with_items(vec![TitleItem::Model, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
mgr.update(&idle_state());
|
||||
assert_eq!(mgr.last_title, "grok");
|
||||
}
|
||||
|
||||
// --- Cwd item ---
|
||||
|
||||
#[test]
|
||||
fn cwd_shows_last_component() {
|
||||
let cfg = config_with_items(vec![TitleItem::Cwd, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = TitleState {
|
||||
cwd: Some("/home/user/my-project"),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "my-project - grok");
|
||||
}
|
||||
|
||||
// --- TurnTimer item ---
|
||||
|
||||
#[test]
|
||||
fn turn_timer_shown_when_above_one_second() {
|
||||
let cfg = config_with_items(vec![TitleItem::TurnTimer, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = TitleState {
|
||||
turn_elapsed: Some(std::time::Duration::from_secs(42)),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "42s - grok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_timer_hidden_when_under_one_second() {
|
||||
let cfg = config_with_items(vec![TitleItem::TurnTimer, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = TitleState {
|
||||
turn_elapsed: Some(std::time::Duration::from_millis(500)),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "grok");
|
||||
}
|
||||
|
||||
// --- Truncation ---
|
||||
|
||||
#[test]
|
||||
fn long_session_name_truncated_with_ellipsis() {
|
||||
let cfg = config_with_items(vec![TitleItem::SessionName]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let long_name = "a".repeat(50);
|
||||
let state = TitleState {
|
||||
session_name: Some(&long_name),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
// 40 chars + ellipsis
|
||||
assert_eq!(mgr.last_title.chars().count(), 41);
|
||||
assert!(mgr.last_title.ends_with('\u{2026}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_session_name_not_truncated() {
|
||||
let cfg = config_with_items(vec![TitleItem::SessionName]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let state = TitleState {
|
||||
session_name: Some("short"),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(mgr.last_title, "short");
|
||||
}
|
||||
|
||||
// --- Reset ---
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state_and_emits_grok() {
|
||||
let cfg = config_with_items(vec![TitleItem::SessionName, TitleItem::Grok]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let activity = TurnActivity::Thinking;
|
||||
let state = TitleState {
|
||||
session_name: Some("test"),
|
||||
activity: Some(&activity),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_ne!(mgr.last_title, "grok");
|
||||
|
||||
mgr.reset();
|
||||
assert_eq!(mgr.last_title, "grok");
|
||||
assert_eq!(mgr.spinner_frame, 0);
|
||||
assert_eq!(mgr.tick_count, 0);
|
||||
}
|
||||
|
||||
// --- Full default config integration ---
|
||||
|
||||
#[test]
|
||||
fn default_config_active_turn_with_permissions() {
|
||||
let cfg = default_config();
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let activity = TurnActivity::Responding;
|
||||
let state = TitleState {
|
||||
session_name: Some("my-session"),
|
||||
activity: Some(&activity),
|
||||
has_pending_permissions: true,
|
||||
focused: false, // unfocused → should blink per original test
|
||||
..idle_state()
|
||||
};
|
||||
|
||||
// First tick: ActionRequired visible.
|
||||
mgr.update(&state);
|
||||
let t1 = mgr.last_title.clone();
|
||||
|
||||
// Advance through the blink divisor to reach the hidden phase.
|
||||
for _ in 1..ACTION_REQUIRED_BLINK_DIVISOR {
|
||||
mgr.update(&state);
|
||||
}
|
||||
mgr.update(&state);
|
||||
let t2 = mgr.last_title.clone();
|
||||
|
||||
// Both should contain the persistent parts.
|
||||
for t in [&t1, &t2] {
|
||||
assert!(t.contains("grok"), "title missing 'grok': {t}");
|
||||
assert!(t.contains("Responding"), "title missing 'Responding': {t}");
|
||||
assert!(t.contains("my-session"), "title missing session name: {t}");
|
||||
}
|
||||
// One should have ActionRequired, the other should not (blinking).
|
||||
let w1 = t1.contains("Action Required");
|
||||
let w2 = t2.contains("Action Required");
|
||||
assert_ne!(w1, w2, "expected blink toggle between t1={t1} and t2={t2}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_idle_no_session() {
|
||||
let cfg = default_config();
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
mgr.update(&idle_state());
|
||||
assert_eq!(mgr.last_title, "grok");
|
||||
}
|
||||
|
||||
// --- Multi-item combinations ---
|
||||
|
||||
#[test]
|
||||
fn all_items_present_in_order() {
|
||||
let cfg = config_with_items(vec![
|
||||
TitleItem::Activity,
|
||||
TitleItem::SessionName,
|
||||
TitleItem::Model,
|
||||
TitleItem::Cwd,
|
||||
TitleItem::Grok,
|
||||
]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let activity = TurnActivity::Thinking;
|
||||
let state = TitleState {
|
||||
session_name: Some("proj"),
|
||||
model: Some("grok-3"),
|
||||
activity: Some(&activity),
|
||||
cwd: Some("/home/user/workspace"),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
assert_eq!(
|
||||
mgr.last_title,
|
||||
"Thinking - proj - grok-3 - workspace - grok"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_title_truncated_in_activity() {
|
||||
let cfg = config_with_items(vec![TitleItem::Activity]);
|
||||
let mut mgr = TitleManager::new(&cfg);
|
||||
let long_tool = "x".repeat(50);
|
||||
let activity = TurnActivity::ToolRunning {
|
||||
title: long_tool,
|
||||
description: None,
|
||||
};
|
||||
let state = TitleState {
|
||||
activity: Some(&activity),
|
||||
..idle_state()
|
||||
};
|
||||
mgr.update(&state);
|
||||
// "Running: " (9 chars) + 30 chars + ellipsis = 40 chars
|
||||
assert!(mgr.last_title.starts_with("Running: "));
|
||||
assert!(mgr.last_title.ends_with('\u{2026}'));
|
||||
}
|
||||
|
||||
/// Remote-sourced title parts must not smuggle control bytes into the
|
||||
/// OSC sequence: the only ESC/BEL in the output is crossterm's framing.
|
||||
#[test]
|
||||
fn title_escape_strips_control_characters() {
|
||||
let esc = build_title_escape("evil\u{1b}]0;pwned\u{7}\r\ntitle");
|
||||
let inner = esc
|
||||
.strip_prefix("\u{1b}]0;")
|
||||
.and_then(|s| s.strip_suffix('\u{7}'))
|
||||
.expect("crossterm OSC 0 framing");
|
||||
assert!(
|
||||
!inner.chars().any(char::is_control),
|
||||
"title payload must be control-free: {inner:?}"
|
||||
);
|
||||
assert_eq!(inner, "evil]0;pwnedtitle");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use crate::terminal::TerminalContext;
|
||||
|
||||
/// Wrap an escape sequence in tmux DCS passthrough.
|
||||
///
|
||||
/// Doubles any embedded ESC bytes so the inner terminal sees them verbatim
|
||||
/// once tmux strips the outer passthrough envelope.
|
||||
pub fn tmux_passthrough(sequence: &str) -> String {
|
||||
let escaped = sequence.replace('\x1b', "\x1b\x1b");
|
||||
format!("\x1bPtmux;{escaped}\x1b\\")
|
||||
}
|
||||
|
||||
/// Returns `true` when the session is tmux-backed and the server version
|
||||
/// is 3.3 or later (the minimum for reliable DCS passthrough).
|
||||
pub fn passthrough_available(ctx: &TerminalContext) -> bool {
|
||||
ctx.is_tmux_backed() && ctx.is_tmux_version_or_later(3, 3)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal::{MultiplexerKind, TerminalContext};
|
||||
|
||||
#[test]
|
||||
fn passthrough_wraps_osc9_sequence() {
|
||||
let seq = "\x1b]9;task done\x07";
|
||||
let wrapped = tmux_passthrough(seq);
|
||||
// ESC bytes doubled, wrapped in DCS tmux; ... ST
|
||||
assert_eq!(wrapped, "\x1bPtmux;\x1b\x1b]9;task done\x07\x1b\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_wraps_osc777_with_st_terminator() {
|
||||
let seq = "\x1b]777;notify;title;body\x1b\\";
|
||||
let wrapped = tmux_passthrough(seq);
|
||||
assert_eq!(
|
||||
wrapped,
|
||||
"\x1bPtmux;\x1b\x1b]777;notify;title;body\x1b\x1b\\\x1b\\"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_no_esc_in_input() {
|
||||
let seq = "plain text";
|
||||
let wrapped = tmux_passthrough(seq);
|
||||
assert_eq!(wrapped, "\x1bPtmux;plain text\x1b\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_bel_has_no_esc_to_double() {
|
||||
let seq = "\x07";
|
||||
let wrapped = tmux_passthrough(seq);
|
||||
assert_eq!(wrapped, "\x1bPtmux;\x07\x1b\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_available_tmux_3_3() {
|
||||
let ctx = TerminalContext {
|
||||
multiplexer: MultiplexerKind::Tmux,
|
||||
tmux_version: Some("tmux 3.3".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(passthrough_available(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_available_tmux_3_4() {
|
||||
let ctx = TerminalContext {
|
||||
multiplexer: MultiplexerKind::Tmux,
|
||||
tmux_version: Some("tmux 3.4".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(passthrough_available(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_unavailable_tmux_3_2() {
|
||||
let ctx = TerminalContext {
|
||||
multiplexer: MultiplexerKind::Tmux,
|
||||
tmux_version: Some("tmux 3.2".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!passthrough_available(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_unavailable_no_tmux() {
|
||||
let ctx = TerminalContext::default();
|
||||
assert!(!passthrough_available(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_unavailable_tmux_no_version() {
|
||||
let ctx = TerminalContext {
|
||||
multiplexer: MultiplexerKind::Tmux,
|
||||
tmux_version: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!passthrough_available(&ctx));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user