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:
Hunter Bown
2026-05-06 19:29:02 -05:00
committed by GitHub
parent 1dcb90760e
commit da047c44ff
6 changed files with 284 additions and 39 deletions
+4
View File
@@ -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.
+21
View File
@@ -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`).
+3
View File
@@ -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()
};
-13
View File
@@ -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
View File
@@ -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 &notif.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,
&current_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.
+121
View File
@@ -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);
}