refactor(tui/ui): move per-turn notification composition into tui::notifications

The notification dispatch primitives (`Method`, `notify_done`,
`humanize_duration`, OSC 9 / OSC 99 / OSC 777 formatters) already live
in `tui/notifications.rs`. The five composition helpers that decided
*when* and *what* to notify still lived in ui.rs:

* `notification_settings`        → `notifications::settings`
* `completed_turn_notification_message` → `notifications::completed_turn_message`
* `subagent_completion_notification_message` → `notifications::subagent_completion_message`
* `latest_assistant_notification_text` → `notifications::latest_assistant_text`
* `notification_text_summary`    → `notifications::text_summary`

Move them so the whole notification stack lives in one file. The lone
streaming sanitizer they share, `sanitize_stream_chunk`, stays in ui.rs
(it has three other call sites in the streaming render path) but is
now `pub(super)` so notifications.rs can reuse it via `super::ui::`.

ui.rs drops to ~9290 lines (down ~735 from the pre-refactor baseline
of 10025). All 954 tui:: tests still pass.
This commit is contained in:
Hunter Bown
2026-05-13 01:45:09 -05:00
parent 7744ee781a
commit 7f0773a5d5
3 changed files with 188 additions and 163 deletions
+167
View File
@@ -270,6 +270,173 @@ pub fn humanize_duration(d: Duration) -> String {
format!("{total}s")
}
// ── Per-turn notification composition ────────────────────────────────
//
// The helpers below decide *whether* to notify on a completed turn and
// *what message* to put in the body. The low-level dispatcher is
// `notify_done`; everything in this block sits in front of it.
use crate::models::{ContentBlock, Message};
use crate::tui::app::App;
/// 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`).
pub fn settings(config: &crate::config::Config) -> Option<(Method, Duration, bool)> {
let notif = config.notifications_config();
let method = match notif.method {
crate::config::NotificationMethod::Auto => Method::Auto,
crate::config::NotificationMethod::Osc9 => Method::Osc9,
crate::config::NotificationMethod::Bel => Method::Bel,
crate::config::NotificationMethod::Kitty => Method::Kitty,
crate::config::NotificationMethod::Ghostty => Method::Ghostty,
crate::config::NotificationMethod::Off => 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.
pub fn completed_turn_message(
app: &App,
current_streaming_text: &str,
include_summary: bool,
turn_elapsed: Duration,
turn_cost: Option<crate::pricing::CostEstimate>,
) -> String {
let mut msg = text_summary(current_streaming_text)
.or_else(|| latest_assistant_text(&app.api_messages))
.unwrap_or_else(|| "deepseek: turn complete".to_string());
if include_summary {
let human = 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
}
/// Compose a notification body for a sub-agent completion. Falls back
/// to a generic "sub-agent X complete" if no human-readable line can
/// be teased out of the child's transcript.
pub fn subagent_completion_message(
id: &str,
result: &str,
include_summary: bool,
elapsed: Duration,
) -> String {
let result_line = result
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with("<deepseek:subagent.done>"));
let mut msg = result_line
.and_then(text_summary)
.map(|summary| format!("sub-agent {id}: {summary}"))
.unwrap_or_else(|| format!("deepseek: sub-agent {id} complete"));
if include_summary {
let human = humanize_duration(elapsed);
msg.push('\n');
msg.push_str(&format!("deepseek: sub-agent complete ({human})"));
}
msg
}
/// Find the latest assistant message in `messages` and return a
/// notification-ready summary of its `Text` content. Thinking blocks,
/// tool calls, and tool results are skipped — only the user-visible
/// reply contributes to the body.
pub fn latest_assistant_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");
text_summary(&text)
})
}
/// Sanitize + collapse + truncate streaming text into something fit to
/// hand the OS notification system. Returns `None` when nothing
/// useful remains after sanitization.
pub fn text_summary(text: &str) -> Option<String> {
const MAX_CHARS: usize = 360;
let sanitized = super::ui::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())
}
}
#[cfg(test)]
mod tests {
use std::sync::{Mutex, OnceLock};
+11 -154
View File
@@ -73,6 +73,7 @@ use crate::tui::auto_router;
use crate::tui::vim_mode;
use crate::tui::streaming_thinking;
use crate::tui::workspace_context;
use crate::tui::notifications;
use crate::tui::onboarding;
use crate::tui::pager::PagerView;
use crate::tui::persistence_actor::{self, PersistRequest};
@@ -1308,10 +1309,10 @@ async fn run_event_loop(
// Emit OSC 9 / BEL desktop notification for long turns.
if status == crate::core::events::TurnOutcomeStatus::Completed
&& let Some((method, threshold, include_summary)) =
notification_settings(config)
notifications::settings(config)
{
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
let msg = completed_turn_notification_message(
let msg = notifications::completed_turn_message(
app,
&current_streaming_text,
include_summary,
@@ -1567,10 +1568,10 @@ async fn run_event_loop(
!has_other_running_subagents && app.use_alt_screen;
if !has_other_running_subagents
&& let Some((method, threshold, include_summary)) =
notification_settings(config)
notifications::settings(config)
{
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
let msg = subagent_completion_notification_message(
let msg = notifications::subagent_completion_message(
&id,
&result,
include_summary,
@@ -3644,7 +3645,10 @@ fn persist_offline_queue_state(app: &App) {
}
}
fn sanitize_stream_chunk(chunk: &str) -> String {
/// Strip ANSI control codes / non-printable bytes from a streaming
/// text chunk. `pub(super)` because `tui::notifications` consumes it
/// from `super::ui` for its per-turn message composition.
pub(super) fn sanitize_stream_chunk(chunk: &str) -> String {
// Keep printable characters and common whitespace; drop control bytes.
chunk
.chars()
@@ -3652,155 +3656,8 @@ 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::Kitty => crate::tui::notifications::Method::Kitty,
crate::config::NotificationMethod::Ghostty => crate::tui::notifications::Method::Ghostty,
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 subagent_completion_notification_message(
id: &str,
result: &str,
include_summary: bool,
elapsed: Duration,
) -> String {
let result_line = result
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with("<deepseek:subagent.done>"));
let mut msg = result_line
.and_then(notification_text_summary)
.map(|summary| format!("sub-agent {id}: {summary}"))
.unwrap_or_else(|| format!("deepseek: sub-agent {id} complete"));
if include_summary {
let human = crate::tui::notifications::humanize_duration(elapsed);
msg.push('\n');
msg.push_str(&format!("deepseek: sub-agent complete ({human})"));
}
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())
}
}
// Per-turn notification composition (settings, message body, summary)
// moved to `tui/notifications.rs` alongside the dispatch primitives.
/// Ensure an in-flight streaming Assistant cell exists in history and return
/// its index. Thinking cells go through `streaming_thinking::ensure_active_entry`
+10 -9
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::config::{ApiProvider, Config};
use crate::tui::active_cell::ActiveCell;
use crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};
use crate::core::engine::mock_engine_handle;
use crate::tui::file_mention::{
@@ -5160,7 +5161,7 @@ fn notification_settings_tui_always_keeps_configured_method_no_threshold() {
};
let (method, threshold, include_summary) =
super::notification_settings(&config).expect("notification should be enabled");
crate::tui::notifications::settings(&config).expect("notification should be enabled");
assert_eq!(method, crate::tui::notifications::Method::Bel);
assert_eq!(threshold, Duration::ZERO);
assert!(include_summary);
@@ -5176,7 +5177,7 @@ fn notification_settings_tui_never_disables_notifications() {
..Config::default()
};
assert!(super::notification_settings(&config).is_none());
assert!(crate::tui::notifications::settings(&config).is_none());
}
#[test]
@@ -5191,7 +5192,7 @@ fn notification_settings_no_tui_override_uses_notifications_block() {
};
let (method, threshold, include_summary) =
super::notification_settings(&config).expect("notification should be enabled");
crate::tui::notifications::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);
@@ -5200,7 +5201,7 @@ fn notification_settings_no_tui_override_uses_notifications_block() {
#[test]
fn completed_turn_notification_uses_streaming_text() {
let app = create_test_app();
let msg = super::completed_turn_notification_message(
let msg = crate::tui::notifications::completed_turn_message(
&app,
"Hello there.\n\nWhat's next?",
false,
@@ -5236,7 +5237,7 @@ fn completed_turn_notification_falls_back_to_latest_assistant_message() {
});
let msg =
super::completed_turn_notification_message(&app, "", false, Duration::from_secs(75), None);
crate::tui::notifications::completed_turn_message(&app, "", false, Duration::from_secs(75), None);
assert_eq!(msg, "Latest reply");
}
@@ -5244,7 +5245,7 @@ fn completed_turn_notification_falls_back_to_latest_assistant_message() {
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);
crate::tui::notifications::completed_turn_message(&app, "", false, Duration::from_secs(5), None);
assert_eq!(msg, "deepseek: turn complete");
}
@@ -5252,7 +5253,7 @@ fn completed_turn_notification_falls_back_to_default_when_empty() {
fn completed_turn_notification_truncates_long_text() {
let app = create_test_app();
let long = "a".repeat(500);
let msg = super::completed_turn_notification_message(
let msg = crate::tui::notifications::completed_turn_message(
&app,
&long,
false,
@@ -5266,7 +5267,7 @@ fn completed_turn_notification_truncates_long_text() {
#[test]
fn subagent_completion_notification_uses_summary_line_not_sentinel() {
let msg = super::subagent_completion_notification_message(
let msg = crate::tui::notifications::subagent_completion_message(
"agent_live",
"Finished the docs audit.\n<deepseek:subagent.done>{}</deepseek:subagent.done>",
false,
@@ -5279,7 +5280,7 @@ fn subagent_completion_notification_uses_summary_line_not_sentinel() {
#[test]
fn subagent_completion_notification_can_include_elapsed_summary() {
let msg = super::subagent_completion_notification_message(
let msg = crate::tui::notifications::subagent_completion_message(
"agent_live",
"",
true,