diff --git a/nebula_app/src/gpui_shell/toast.rs b/nebula_app/src/gpui_shell/toast.rs index 487f384..ae89218 100644 --- a/nebula_app/src/gpui_shell/toast.rs +++ b/nebula_app/src/gpui_shell/toast.rs @@ -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 { .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) { 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( diff --git a/nebula_app/src/notify.rs b/nebula_app/src/notify.rs index 3bd18be..7d25cd1 100644 --- a/nebula_app/src/notify.rs +++ b/nebula_app/src/notify.rs @@ -106,10 +106,38 @@ pub enum Notification { AiTurn { program: String, message: Option, 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() {