fix(toast): bound notification height so a long agent message can't cover the UI

Codex's turn-complete notification carries the model's
`last-assistant-message` (bounded only at 4 000 chars in `ai_hook`) and an
OSC 9 body is unbounded. Rendered at a fixed 440 px width that became a card
taller than the window: anchored bottom-right it overflowed off the top,
taking its hover-only close button out of reach, and covered a split pane
for the whole 90 s banner TTL.

Notification bodies are now clamped to `TOAST_BODY_MAX_CHARS`; the full text
stays in the log and in the terminal's answer reader. The notification layer
clips to the window, and plain banners dismiss on click.
This commit is contained in:
YinBuLiao committed 2026-09-11 16:24:03 +08:00
1 parent c2deb165d6
commit ce2eb53162
2 files changed
+69 -2

No files matched your search

+14 -1
View File
@@ -79,6 +79,11 @@ fn is_duplicate(text: &str) -> bool {
}
fn note(kind: ToastKind, text: String) -> Notification {
// 通知栏是「一眼带过」的层。正文先收短:模型回合完成的正文可能是整段
// 回答(OSC 9 更是无界),不设上限时按固定宽度排出来比窗口还高,锚在
// 右下就会整条溢出屏幕、把关闭按钮顶到看不见的地方。完整原文仍在日志
// 与终端的「阅读 / 原文」里。
let text = crate::notify::clamp_toast_body(&text);
let note = match kind {
ToastKind::Info => Notification::info(text),
ToastKind::Success => Notification::success(text),
@@ -132,6 +137,10 @@ pub fn render_layer(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
.items_end()
.justify_end()
.p(px(20.0))
// 硬保证:任何一条通知都不可能画到窗口之外。正文长度已在
// [`note`] 收短,这里是兜底——溢出到屏幕外的卡片连关闭按钮都
// 够不着。
.overflow_hidden()
// 用全视口透明宿主明确右下锚点;宿主不注册命中,只有实际
// Notification 卡片会 occlude/接收鼠标,周围终端仍可正常点击。
.child(v_flex().items_end().gap_2().children(items))
@@ -151,13 +160,17 @@ pub fn toast(window: &mut Window, cx: &mut App, kind: ToastKind, text: impl Into
/// 驻留一条消息(消息栏层):停留远长于 toast,但**有上限**,见
/// [`BANNER_TTL`]。用于「值得驻留」的事——没什么值得驻留的请用 [`toast`]。
///
/// 这里也挂一个空的 `on_click`:组件库的点击包装会先 `dismiss` 再调回调,
/// 所以整张卡片点一下就消失。驻留消息的关闭按钮只在悬停时出现,没有这条
/// 路径的话「怎么都关不掉」就是字面事实。
pub fn banner(window: &mut Window, cx: &mut App, kind: ToastKind, text: impl Into<String>) {
let text = text.into();
if text.trim().is_empty() {
return;
}
log::warn!("banner [{kind:?}]: {text}");
push_banner(window, cx, note(kind, text));
push_banner(window, cx, note(kind, text).on_click(|_, _, _| {}));
}
pub(crate) fn banner_for_pane(
+55 -1
View File
@@ -106,10 +106,38 @@ pub enum Notification {
AiTurn { program: String, message: Option<String>, attention: bool },
}
/// Longest notification body any shell may render.
///
/// A toast/banner is a glance layer, not a reader. Codex's turn-complete
/// notification carries the model's `last-assistant-message` (bounded only at
/// 4 000 chars in `ai_hook`), and an OSC 9 body is unbounded; rendered at a
/// fixed width that becomes a card taller than the window. Anchored to the
/// bottom-right it then overflows off the top, taking its close button out of
/// reach, and sits there for the whole banner TTL while covering whatever is
/// underneath (including a split pane). Only the preview is bounded here — the
/// full text stays in the log and in the terminal's answer reader.
pub(crate) const TOAST_BODY_MAX_CHARS: usize = 160;
/// Clamp a notification body to [`TOAST_BODY_MAX_CHARS`], ending with `…` when
/// anything was dropped. The ellipsis replaces the last kept character, so the
/// result never exceeds the limit and clamping again is a no-op.
pub(crate) fn clamp_toast_body(body: &str) -> String {
if body.chars().count() <= TOAST_BODY_MAX_CHARS {
return body.to_owned();
}
let head: String = body.chars().take(TOAST_BODY_MAX_CHARS - 1).collect();
format!("{head}…")
}
impl Notification {
/// Toast title + body. Title names the source ("Pebrel" or the program);
/// body carries the human detail.
/// body carries the human detail, bounded to a glanceable length.
pub(crate) fn toast_text(&self) -> (String, String) {
let (title, body) = self.raw_toast_text();
(title, clamp_toast_body(&body))
}
fn raw_toast_text(&self) -> (String, String) {
match self {
Self::Bell { program } => match program {
Some(p) => (p.clone(), "任务完成,等待输入".to_owned()),
@@ -303,6 +331,32 @@ mod delivery_tests {
);
}
/// 回归锁:一篇很长的模型回答不能变成一张撑破窗口的通知卡。正文只保留
/// 一眼能读的预览,其余在日志和终端「原文」里。
#[test]
fn oversized_notification_bodies_are_clamped_to_a_glanceable_preview() {
let long = "回答正文。".repeat(1_000);
let done = Notification::AiTurn {
program: "codex".to_owned(),
message: Some(long.clone()),
attention: false,
};
let (title, body) = done.toast_text();
assert_eq!(title, "codex");
assert!(body.chars().count() <= TOAST_BODY_MAX_CHARS, "正文必须被收短: {}", body.len());
assert!(body.ends_with('…'), "被截断的正文要以省略号收尾");
assert!(long.starts_with(body.trim_end_matches('…')), "保留的是原文开头");
// OSC 9 原文同样无界,走 Text 分支也要收短。
let (_, osc) =
Notification::Text { body: long, program: Some("claude".to_owned()) }.toast_text();
assert!(osc.chars().count() <= TOAST_BODY_MAX_CHARS);
// 幂等:已经收短过的正文再收一次不会多出第二个省略号。
let once = clamp_toast_body(&"x".repeat(1_000));
assert_eq!(clamp_toast_body(&once), once);
}
#[cfg(feature = "gpui-shell")]
#[test]
fn activation_preserves_exact_pane_and_generic_application_targets() {