feat(tui): notification_condition override + assistant text in OSC 9 body (#920)
* feat(tui): add `notification_condition` override + assistant text in body (#820) `[notifications]` already controls method (auto/osc9/bel/off), the `threshold_secs` gate, and the `include_summary` body. Some users want a simpler high-level switch — "always notify on every turn" or "never notify" — without having to know the lower-level fields. This adds a single optional `[tui].notification_condition` field: - `"always"` — notify on every successful turn (no duration threshold). The configured `[notifications].method` and `include_summary` flag are still respected. - `"never"` — suppress all turn-completion notifications. - omitted — fall back to the existing `[notifications]` defaults (the v0.8.15 behavior is unchanged). The OSC 9 / BEL body now also carries the assistant's reply text, sanitized and truncated to 360 characters, with a fallback to the latest assistant message in `api_messages` when the streaming buffer was empty (e.g. a tool-only turn). When `include_summary = true`, the elapsed/cost line is appended on a new line. Drive-by: drop the unused `Method::from_str` helper (the new code match-arms over the typed `NotificationMethod` enum, so the parser helper had no remaining callers). Differences from upstream #820: - Keeps the `[notifications]` section in `config.example.toml` (with `notification_condition` documented as an opt-in override) rather than deleting the existing block. This avoids breaking configs that already set `[notifications].method` etc. - Drops the unrelated `#[allow(dead_code)]` on `schema_migration::registry`. - Threads `Option<CostEstimate>` through the helper (the cost surface changed from `Option<f64>` since #820 was authored). Tests: - `notification_settings_*` (3) — `always` keeps the configured method, `never` returns `None`, missing override falls back. - `completed_turn_notification_*` (4) — streaming text wins, falls back to latest assistant message, default placeholder, and 360-char truncation with `...`. Integrates #820. Co-authored-by: zero <1603852@qq.com> Co-authored-by: zerx-lab <161401688+zerx-lab@users.noreply.github.com> * style: fmt — collapse short test message helper calls --------- Co-authored-by: zero <1603852@qq.com> Co-authored-by: zerx-lab <161401688+zerx-lab@users.noreply.github.com>
This commit is contained in:
@@ -241,6 +241,10 @@ alternate_screen = "auto" # auto | always | never
|
||||
mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy
|
||||
terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms)
|
||||
osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender
|
||||
# notification_condition = "always" # always | never — overrides [notifications].threshold_secs.
|
||||
# "always" = notify on every successful turn (no threshold);
|
||||
# "never" = suppress all turn-completion notifications;
|
||||
# unset = use [notifications] defaults (recommended).
|
||||
# locale = "auto" # UI chrome language: auto | en | ja | zh-Hans | pt-BR
|
||||
# # "auto" reads LC_ALL → LC_MESSAGES → LANG; falls back to English.
|
||||
# # Override: `locale = "zh-Hans"` for Simplified Chinese regardless of OS locale.
|
||||
|
||||
@@ -289,6 +289,27 @@ pub struct TuiConfig {
|
||||
/// label and ignore the escape. Defaults to `true`; set `false` for
|
||||
/// terminals that misrender the sequence.
|
||||
pub osc8_links: Option<bool>,
|
||||
/// High-level notification trigger condition. When set, overrides the
|
||||
/// `[notifications].threshold_secs` gate from the lower-level
|
||||
/// `[notifications]` block:
|
||||
///
|
||||
/// - `Always` — fire a turn-completion notification on every successful
|
||||
/// turn regardless of duration. The configured `[notifications].method`
|
||||
/// and `include_summary` flag are still respected.
|
||||
/// - `Never` — suppress all turn-completion notifications.
|
||||
/// - Unset (default) — fall back to the `[notifications]` defaults.
|
||||
pub notification_condition: Option<NotificationCondition>,
|
||||
}
|
||||
|
||||
/// High-level notification trigger override. See
|
||||
/// [`TuiConfig::notification_condition`].
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NotificationCondition {
|
||||
/// Notify on every successful turn (no duration threshold).
|
||||
Always,
|
||||
/// Suppress notifications entirely.
|
||||
Never,
|
||||
}
|
||||
|
||||
/// Notification delivery method (mirrors `tui::notifications::Method`).
|
||||
|
||||
@@ -4348,6 +4348,7 @@ mod terminal_mode_tests {
|
||||
terminal_probe_timeout_ms: None,
|
||||
status_items: None,
|
||||
osc8_links: None,
|
||||
notification_condition: None,
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
@@ -4373,6 +4374,7 @@ mod terminal_mode_tests {
|
||||
terminal_probe_timeout_ms: None,
|
||||
status_items: None,
|
||||
osc8_links: None,
|
||||
notification_condition: None,
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
@@ -4446,6 +4448,7 @@ mod terminal_mode_tests {
|
||||
terminal_probe_timeout_ms: None,
|
||||
status_items: None,
|
||||
osc8_links: None,
|
||||
notification_condition: None,
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
@@ -34,19 +34,6 @@ pub enum Method {
|
||||
Off,
|
||||
}
|
||||
|
||||
impl Method {
|
||||
/// Parse from a configuration string (case-insensitive).
|
||||
#[must_use]
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"osc9" | "osc-9" => Self::Osc9,
|
||||
"bel" => Self::Bel,
|
||||
"off" | "disabled" | "none" => Self::Off,
|
||||
_ => Self::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a Windows system beep via `MessageBeep(MB_OK)`.
|
||||
///
|
||||
/// Writing BEL (`\\x07`) to the terminal is silent on most Windows
|
||||
|
||||
+135
-26
@@ -956,33 +956,18 @@ async fn run_event_loop(
|
||||
}
|
||||
|
||||
// Emit OSC 9 / BEL desktop notification for long turns.
|
||||
if status == crate::core::events::TurnOutcomeStatus::Completed {
|
||||
let notif = config.notifications_config();
|
||||
let method =
|
||||
crate::tui::notifications::Method::from_str(match ¬if.method {
|
||||
crate::config::NotificationMethod::Auto => "auto",
|
||||
crate::config::NotificationMethod::Osc9 => "osc9",
|
||||
crate::config::NotificationMethod::Bel => "bel",
|
||||
crate::config::NotificationMethod::Off => "off",
|
||||
});
|
||||
let threshold = std::time::Duration::from_secs(notif.threshold_secs);
|
||||
if status == crate::core::events::TurnOutcomeStatus::Completed
|
||||
&& let Some((method, threshold, include_summary)) =
|
||||
notification_settings(config)
|
||||
{
|
||||
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
|
||||
let msg = if notif.include_summary {
|
||||
let human =
|
||||
crate::tui::notifications::humanize_duration(turn_elapsed);
|
||||
match turn_cost {
|
||||
Some(c) => {
|
||||
let cost = crate::pricing::format_cost_estimate(
|
||||
c,
|
||||
app.cost_currency,
|
||||
);
|
||||
format!("deepseek: turn complete ({human}, {cost})")
|
||||
}
|
||||
None => format!("deepseek: turn complete ({human})"),
|
||||
}
|
||||
} else {
|
||||
"deepseek: turn complete".to_string()
|
||||
};
|
||||
let msg = completed_turn_notification_message(
|
||||
app,
|
||||
¤t_streaming_text,
|
||||
include_summary,
|
||||
turn_elapsed,
|
||||
turn_cost,
|
||||
);
|
||||
crate::tui::notifications::notify_done(
|
||||
method,
|
||||
in_tmux,
|
||||
@@ -3030,6 +3015,130 @@ fn sanitize_stream_chunk(chunk: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve the effective notification method/threshold/include-summary tuple
|
||||
/// for a completed turn, taking the high-level
|
||||
/// `[tui].notification_condition` override into account on top of the
|
||||
/// lower-level `[notifications]` block.
|
||||
///
|
||||
/// Returns `None` to mean "do not notify" (either because the user set
|
||||
/// `notification_condition = "never"` or because the resolved method is
|
||||
/// `Off`).
|
||||
fn notification_settings(
|
||||
config: &Config,
|
||||
) -> Option<(crate::tui::notifications::Method, Duration, bool)> {
|
||||
let notif = config.notifications_config();
|
||||
let method = match notif.method {
|
||||
crate::config::NotificationMethod::Auto => crate::tui::notifications::Method::Auto,
|
||||
crate::config::NotificationMethod::Osc9 => crate::tui::notifications::Method::Osc9,
|
||||
crate::config::NotificationMethod::Bel => crate::tui::notifications::Method::Bel,
|
||||
crate::config::NotificationMethod::Off => crate::tui::notifications::Method::Off,
|
||||
};
|
||||
|
||||
if let Some(condition) = config
|
||||
.tui
|
||||
.as_ref()
|
||||
.and_then(|tui| tui.notification_condition)
|
||||
{
|
||||
match condition {
|
||||
crate::config::NotificationCondition::Always => {
|
||||
return Some((method, Duration::ZERO, notif.include_summary));
|
||||
}
|
||||
crate::config::NotificationCondition::Never => return None,
|
||||
}
|
||||
}
|
||||
|
||||
Some((
|
||||
method,
|
||||
Duration::from_secs(notif.threshold_secs),
|
||||
notif.include_summary,
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the notification body for a completed turn. Prefers the live
|
||||
/// streaming text the user just saw; falls back to the latest assistant
|
||||
/// message in `api_messages` if streaming text is empty (for example, the
|
||||
/// turn finished entirely through tool output). When `include_summary` is
|
||||
/// true, an elapsed/cost line is appended.
|
||||
fn completed_turn_notification_message(
|
||||
app: &App,
|
||||
current_streaming_text: &str,
|
||||
include_summary: bool,
|
||||
turn_elapsed: Duration,
|
||||
turn_cost: Option<crate::pricing::CostEstimate>,
|
||||
) -> String {
|
||||
let mut msg = notification_text_summary(current_streaming_text)
|
||||
.or_else(|| latest_assistant_notification_text(&app.api_messages))
|
||||
.unwrap_or_else(|| "deepseek: turn complete".to_string());
|
||||
|
||||
if include_summary {
|
||||
let human = crate::tui::notifications::humanize_duration(turn_elapsed);
|
||||
let summary = match turn_cost {
|
||||
Some(c) => {
|
||||
let cost = crate::pricing::format_cost_estimate(c, app.cost_currency);
|
||||
format!("deepseek: turn complete ({human}, {cost})")
|
||||
}
|
||||
None => format!("deepseek: turn complete ({human})"),
|
||||
};
|
||||
if msg == "deepseek: turn complete" {
|
||||
msg = summary;
|
||||
} else {
|
||||
msg.push('\n');
|
||||
msg.push_str(&summary);
|
||||
}
|
||||
}
|
||||
|
||||
msg
|
||||
}
|
||||
|
||||
fn latest_assistant_notification_text(messages: &[Message]) -> Option<String> {
|
||||
messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|message| message.role == "assistant")
|
||||
.and_then(|message| {
|
||||
let text = message
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
ContentBlock::Thinking { .. }
|
||||
| ContentBlock::ToolUse { .. }
|
||||
| ContentBlock::ToolResult { .. }
|
||||
| ContentBlock::ServerToolUse { .. }
|
||||
| ContentBlock::ToolSearchToolResult { .. }
|
||||
| ContentBlock::CodeExecutionToolResult { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
notification_text_summary(&text)
|
||||
})
|
||||
}
|
||||
|
||||
fn notification_text_summary(text: &str) -> Option<String> {
|
||||
const MAX_CHARS: usize = 360;
|
||||
|
||||
let sanitized = sanitize_stream_chunk(text);
|
||||
let collapsed = sanitized
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let trimmed = collapsed.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some((idx, _)) = trimmed.char_indices().nth(MAX_CHARS) {
|
||||
let mut s = String::with_capacity(idx + 3);
|
||||
s.push_str(&trimmed[..idx]);
|
||||
s.push_str("...");
|
||||
Some(s)
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure an in-flight streaming Assistant cell exists in history and return
|
||||
/// its index. Thinking cells go through `ensure_streaming_thinking_active_entry`
|
||||
/// (active cell) instead.
|
||||
|
||||
@@ -662,6 +662,7 @@ fn terminal_probe_timeout_uses_tui_config_and_clamps() {
|
||||
terminal_probe_timeout_ms: Some(750),
|
||||
status_items: None,
|
||||
osc8_links: None,
|
||||
notification_condition: None,
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
@@ -3390,3 +3391,123 @@ fn scroll_with_arrows_returns_false_when_input_has_text() {
|
||||
"text in composer: Up/Down should navigate history"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_settings_tui_always_keeps_configured_method_no_threshold() {
|
||||
let config = Config {
|
||||
tui: Some(crate::config::TuiConfig {
|
||||
notification_condition: Some(crate::config::NotificationCondition::Always),
|
||||
..Default::default()
|
||||
}),
|
||||
notifications: Some(crate::config::NotificationsConfig {
|
||||
method: crate::config::NotificationMethod::Bel,
|
||||
threshold_secs: 120,
|
||||
include_summary: true,
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
let (method, threshold, include_summary) =
|
||||
super::notification_settings(&config).expect("notification should be enabled");
|
||||
assert_eq!(method, crate::tui::notifications::Method::Bel);
|
||||
assert_eq!(threshold, Duration::ZERO);
|
||||
assert!(include_summary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_settings_tui_never_disables_notifications() {
|
||||
let config = Config {
|
||||
tui: Some(crate::config::TuiConfig {
|
||||
notification_condition: Some(crate::config::NotificationCondition::Never),
|
||||
..Default::default()
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
assert!(super::notification_settings(&config).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_settings_no_tui_override_uses_notifications_block() {
|
||||
let config = Config {
|
||||
notifications: Some(crate::config::NotificationsConfig {
|
||||
method: crate::config::NotificationMethod::Osc9,
|
||||
threshold_secs: 45,
|
||||
include_summary: false,
|
||||
}),
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
let (method, threshold, include_summary) =
|
||||
super::notification_settings(&config).expect("notification should be enabled");
|
||||
assert_eq!(method, crate::tui::notifications::Method::Osc9);
|
||||
assert_eq!(threshold, Duration::from_secs(45));
|
||||
assert!(!include_summary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_turn_notification_uses_streaming_text() {
|
||||
let app = create_test_app();
|
||||
let msg = super::completed_turn_notification_message(
|
||||
&app,
|
||||
"Hello there.\n\nWhat's next?",
|
||||
false,
|
||||
Duration::from_secs(12),
|
||||
None,
|
||||
);
|
||||
assert_eq!(msg, "Hello there.\nWhat's next?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_turn_notification_falls_back_to_latest_assistant_message() {
|
||||
let mut app = create_test_app();
|
||||
app.api_messages.push(crate::models::Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![crate::models::ContentBlock::Text {
|
||||
text: "Earlier turn".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
app.api_messages.push(crate::models::Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![crate::models::ContentBlock::Text {
|
||||
text: "next".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
app.api_messages.push(crate::models::Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![crate::models::ContentBlock::Text {
|
||||
text: "Latest reply".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let msg =
|
||||
super::completed_turn_notification_message(&app, "", false, Duration::from_secs(75), None);
|
||||
assert_eq!(msg, "Latest reply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_turn_notification_falls_back_to_default_when_empty() {
|
||||
let app = create_test_app();
|
||||
let msg =
|
||||
super::completed_turn_notification_message(&app, "", false, Duration::from_secs(5), None);
|
||||
assert_eq!(msg, "deepseek: turn complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_turn_notification_truncates_long_text() {
|
||||
let app = create_test_app();
|
||||
let long = "a".repeat(500);
|
||||
let msg = super::completed_turn_notification_message(
|
||||
&app,
|
||||
&long,
|
||||
false,
|
||||
Duration::from_secs(5),
|
||||
None,
|
||||
);
|
||||
assert!(msg.ends_with("..."));
|
||||
// 360-char body + 3-char ellipsis
|
||||
assert_eq!(msg.chars().count(), 363);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user