From eba2a0bc40e1a45bb9899cb1a9afc5b8d62aec72 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 16:48:11 +0700 Subject: [PATCH 1/4] Fit the tray tooltip into the Windows 127-unit limit --- .../src-tauri/src/tray_bridge.rs | 196 +++++++++++++++++- 1 file changed, 189 insertions(+), 7 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index fd594513d9..0560d4eafa 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -441,6 +441,7 @@ pub fn update_tray_icon_and_tooltip( settings.hide_personal_info, ); let tooltip = crate::status_poller::with_incident_lines(app, &settings, tooltip); + let tooltip = fit_tooltip(&tooltip, TRAY_TOOLTIP_MAX_UTF16); let _ = tray.set_tooltip(Some(tooltip)); } @@ -466,7 +467,9 @@ fn build_tooltip( Some(err), hide_personal_info, ) - .unwrap_or_default(); + .unwrap_or_default() + // A newline would split one status row into two tooltip lines. + .replace(['\r', '\n'], " "); let short = truncate_tooltip_text(&err, 36); format!("{}: {} ({})", s.display_name, error_label, short) } else { @@ -479,14 +482,76 @@ fn build_tooltip( format!("CodexBar\n{}", lines.join("\n")) } -fn truncate_tooltip_text(text: &str, max_chars: usize) -> String { - let mut chars = text.chars(); - let truncated: String = chars.by_ref().take(max_chars).collect(); - if chars.next().is_some() { - format!("{truncated}...") +/// `NOTIFYICONDATAW.szTip` holds 128 UTF-16 units including the trailing NUL, +/// and tray-icon copies at most 127 of them, cutting mid-line without a marker. +const TRAY_TOOLTIP_MAX_UTF16: usize = 127; + +fn utf16_len(text: &str) -> usize { + text.encode_utf16().count() +} + +/// Longest char-boundary prefix of `text` that fits in `budget` UTF-16 units. +/// A surrogate pair is never split. +fn take_utf16(text: &str, budget: usize) -> String { + let mut out = String::new(); + let mut used = 0; + for ch in text.chars() { + let width = ch.len_utf16(); + if used + width > budget { + break; + } + used += width; + out.push(ch); + } + out +} + +fn truncate_tooltip_text(text: &str, max_units: usize) -> String { + if utf16_len(text) <= max_units { + return text.to_string(); + } + format!("{}...", take_utf16(text, max_units)) +} + +/// Fits a newline-separated tooltip into `max_units` UTF-16 units. Whole lines +/// are kept in order (the title, then incident lines, then provider lines). +/// When lines are dropped, a "+N more" line is added if it fits; otherwise the +/// last kept line ends with "…". A single line that is too long is cut with "…". +fn fit_tooltip(tooltip: &str, max_units: usize) -> String { + if utf16_len(tooltip) <= max_units { + return tooltip.to_string(); + } + let lines: Vec<&str> = tooltip.split('\n').collect(); + + // Every candidate line reserves one unit for the "…" that would follow it + // when more lines come after it. The reserved unit is always available. + let mut kept = 0; + let mut used = 0; + for (index, line) in lines.iter().enumerate() { + let separator = usize::from(kept > 0); + let reserve = usize::from(index + 1 < lines.len()); + let cost = separator + utf16_len(line); + if used + cost + reserve > max_units { + break; + } + used += cost; + kept += 1; + } + + if kept == 0 { + let budget = max_units.saturating_sub(1); + return format!("{}…", take_utf16(lines[0], budget)); + } + + let more = format!("+{} more", lines.len() - kept); + let mut out: Vec = lines[..kept].iter().map(|line| line.to_string()).collect(); + if used + 1 + utf16_len(&more) <= max_units { + out.push(more); } else { - truncated + let last = out.len() - 1; + out[last].push('…'); } + out.join("\n") } enum NativeMenuEntry { @@ -992,4 +1057,121 @@ mod tests { assert!(english_label.contains("Resets in"), "{english_label}"); assert!(japanese_label.contains("リセットまで"), "{japanese_label}"); } + + #[test] + fn fit_tooltip_keeps_short_tooltips_unchanged() { + let tooltip = "CodexBar\nClaude: 87% left\nCodex: 92% left"; + assert_eq!(fit_tooltip(tooltip, TRAY_TOOLTIP_MAX_UTF16), tooltip); + } + + #[test] + fn fit_tooltip_stays_within_the_windows_limit_and_keeps_whole_lines() { + let mut lines = vec!["CodexBar".to_string()]; + for index in 0..20 { + lines.push(format!( + "Provider {index}: 50% left • Resets in 3d 17h with a long suffix" + )); + } + let tooltip = lines.join("\n"); + + let fitted = fit_tooltip(&tooltip, TRAY_TOOLTIP_MAX_UTF16); + + assert!(utf16_len(&fitted) <= TRAY_TOOLTIP_MAX_UTF16, "{fitted}"); + let shown: Vec<&str> = fitted.split('\n').collect(); + assert_eq!(shown[0], "CodexBar", "{fitted}"); + assert!(shown.len() > 2, "{fitted}"); + // Either a "+N more" marker or an ellipsis on the last kept line. + let last = shown.last().expect("last line"); + assert!( + last.ends_with('…') || (last.starts_with('+') && last.ends_with(" more")), + "{fitted}" + ); + for line in &shown[1..shown.len() - 1] { + assert!(lines.contains(&line.to_string()), "split line: {line}"); + } + } + + #[test] + fn fit_tooltip_ends_the_last_kept_line_with_an_ellipsis_when_more_does_not_fit() { + // Title plus one line that uses almost the whole budget: no room for + // a "+N more" line, so the kept line takes the ellipsis. + let long = "A".repeat(115); + let tooltip = format!("CodexBar\n{long}\nB line that is dropped entirely here"); + + let fitted = fit_tooltip(&tooltip, TRAY_TOOLTIP_MAX_UTF16); + + assert!(utf16_len(&fitted) <= TRAY_TOOLTIP_MAX_UTF16, "{fitted}"); + assert_eq!(fitted, format!("CodexBar\n{long}…")); + } + + #[test] + fn fit_tooltip_keeps_incident_lines_under_the_title_before_provider_lines() { + let tooltip = [ + "CodexBar".to_string(), + "OpenAI: Major outage".to_string(), + "Claude: 87% left".to_string(), + "A".repeat(120), + ] + .join("\n"); + + let fitted = fit_tooltip(&tooltip, TRAY_TOOLTIP_MAX_UTF16); + + assert!(fitted.starts_with("CodexBar\nOpenAI: Major outage\nClaude: 87% left\n+1 more")); + assert!(utf16_len(&fitted) <= TRAY_TOOLTIP_MAX_UTF16); + } + + #[test] + fn fit_tooltip_drops_an_oversized_line_for_a_marker() { + // The 200-unit emoji line cannot fit beside the title, so it is dropped. + let tooltip = format!("CodexBar\n{}", "🙂".repeat(100)); + + let fitted = fit_tooltip(&tooltip, TRAY_TOOLTIP_MAX_UTF16); + + assert_eq!(fitted, "CodexBar\n+1 more"); + } + + #[test] + fn fit_tooltip_cuts_an_oversized_first_line_without_splitting_a_surrogate_pair() { + // Each emoji is two UTF-16 units; a char-count cut would overflow or split a pair. + let tooltip = format!("{}\nCodexBar", "🙂".repeat(100)); + + let fitted = fit_tooltip(&tooltip, TRAY_TOOLTIP_MAX_UTF16); + + assert!(utf16_len(&fitted) <= TRAY_TOOLTIP_MAX_UTF16, "{fitted}"); + assert_eq!(fitted.chars().filter(|ch| *ch == '🙂').count(), 63); + assert!(fitted.ends_with('…'), "{fitted}"); + assert!(!fitted.contains('\n'), "{fitted}"); + } + + #[test] + fn fit_tooltip_handles_a_first_line_longer_than_the_limit() { + let fitted = fit_tooltip(&"중".repeat(200), TRAY_TOOLTIP_MAX_UTF16); + + assert!(utf16_len(&fitted) <= TRAY_TOOLTIP_MAX_UTF16, "{fitted}"); + assert!(fitted.ends_with('…')); + assert!(!fitted.contains('\n')); + } + + #[test] + fn truncate_tooltip_text_counts_utf16_units_not_chars() { + // Four emoji are 8 UTF-16 units; a 6-unit budget keeps 3 emoji. + let truncated = truncate_tooltip_text(&"🙂".repeat(4), 6); + assert_eq!(truncated, "🙂🙂🙂..."); + assert_eq!(truncate_tooltip_text("short", 36), "short"); + } + + #[test] + fn provider_error_newlines_do_not_split_the_tooltip_line() { + let mut codex = fake_snapshot("codex", "Codex", 13.0); + codex.error = Some("first line\nsecond line".to_string()); + + let tooltip = build_tooltip( + &[codex], + &Settings::default(), + codexbar::settings::Language::English, + false, + ); + + assert_eq!(tooltip.lines().count(), 2, "{tooltip}"); + } } From b07487d343edb21774670312e26317e17e93cad9 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 16:49:47 +0700 Subject: [PATCH 2/4] Mark dropped tooltip lines with an ellipsis only --- .../src-tauri/src/tray_bridge.rs | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 0560d4eafa..ea47b48f51 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -515,8 +515,9 @@ fn truncate_tooltip_text(text: &str, max_units: usize) -> String { /// Fits a newline-separated tooltip into `max_units` UTF-16 units. Whole lines /// are kept in order (the title, then incident lines, then provider lines). -/// When lines are dropped, a "+N more" line is added if it fits; otherwise the -/// last kept line ends with "…". A single line that is too long is cut with "…". +/// When lines are dropped, the last kept line ends with "…" (a symbol, so no +/// new localized string is needed). A single line that is too long is cut +/// with "…". fn fit_tooltip(tooltip: &str, max_units: usize) -> String { if utf16_len(tooltip) <= max_units { return tooltip.to_string(); @@ -543,14 +544,9 @@ fn fit_tooltip(tooltip: &str, max_units: usize) -> String { return format!("{}…", take_utf16(lines[0], budget)); } - let more = format!("+{} more", lines.len() - kept); let mut out: Vec = lines[..kept].iter().map(|line| line.to_string()).collect(); - if used + 1 + utf16_len(&more) <= max_units { - out.push(more); - } else { - let last = out.len() - 1; - out[last].push('…'); - } + let last = out.len() - 1; + out[last].push('…'); out.join("\n") } @@ -1080,21 +1076,17 @@ mod tests { let shown: Vec<&str> = fitted.split('\n').collect(); assert_eq!(shown[0], "CodexBar", "{fitted}"); assert!(shown.len() > 2, "{fitted}"); - // Either a "+N more" marker or an ellipsis on the last kept line. let last = shown.last().expect("last line"); - assert!( - last.ends_with('…') || (last.starts_with('+') && last.ends_with(" more")), - "{fitted}" - ); + assert!(last.ends_with('…'), "{fitted}"); for line in &shown[1..shown.len() - 1] { assert!(lines.contains(&line.to_string()), "split line: {line}"); } } #[test] - fn fit_tooltip_ends_the_last_kept_line_with_an_ellipsis_when_more_does_not_fit() { - // Title plus one line that uses almost the whole budget: no room for - // a "+N more" line, so the kept line takes the ellipsis. + fn fit_tooltip_ends_the_last_kept_line_with_an_ellipsis_when_lines_are_dropped() { + // Title plus one line that uses almost the whole budget; the dropped + // third line leaves the kept line with the ellipsis, which still fits. let long = "A".repeat(115); let tooltip = format!("CodexBar\n{long}\nB line that is dropped entirely here"); @@ -1116,18 +1108,18 @@ mod tests { let fitted = fit_tooltip(&tooltip, TRAY_TOOLTIP_MAX_UTF16); - assert!(fitted.starts_with("CodexBar\nOpenAI: Major outage\nClaude: 87% left\n+1 more")); + assert_eq!(fitted, "CodexBar\nOpenAI: Major outage\nClaude: 87% left…"); assert!(utf16_len(&fitted) <= TRAY_TOOLTIP_MAX_UTF16); } #[test] - fn fit_tooltip_drops_an_oversized_line_for_a_marker() { + fn fit_tooltip_drops_an_oversized_line_and_marks_the_title() { // The 200-unit emoji line cannot fit beside the title, so it is dropped. let tooltip = format!("CodexBar\n{}", "🙂".repeat(100)); let fitted = fit_tooltip(&tooltip, TRAY_TOOLTIP_MAX_UTF16); - assert_eq!(fitted, "CodexBar\n+1 more"); + assert_eq!(fitted, "CodexBar…"); } #[test] From 74a52af684e4999eaf55b922e9312353c06d2b28 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 18:45:21 +0700 Subject: [PATCH 3/4] Fit tray tooltip to the legacy 64-unit tip --- .../src-tauri/src/tray_bridge.rs | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index ea47b48f51..bd28fa9e6b 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -482,9 +482,11 @@ fn build_tooltip( format!("CodexBar\n{}", lines.join("\n")) } -/// `NOTIFYICONDATAW.szTip` holds 128 UTF-16 units including the trailing NUL, -/// and tray-icon copies at most 127 of them, cutting mid-line without a marker. -const TRAY_TOOLTIP_MAX_UTF16: usize = 127; +/// `NOTIFYICONDATAW.szTip` holds 128 UTF-16 units, but tray-icon 0.21 leaves +/// `cbSize` zeroed, so Explorer reads the pre-Windows 2000 layout and shows +/// only the first 64 units, cutting mid-line without a marker. 63 leaves room +/// for the terminating NUL of that legacy `szTip[64]`. +const TRAY_TOOLTIP_MAX_UTF16: usize = 63; fn utf16_len(text: &str) -> usize { text.encode_utf16().count() @@ -1054,6 +1056,9 @@ mod tests { assert!(japanese_label.contains("リセットまで"), "{japanese_label}"); } + /// The full `szTip[128]` budget, so layout tests can use realistic line lengths. + const WIDE_BUDGET: usize = 127; + #[test] fn fit_tooltip_keeps_short_tooltips_unchanged() { let tooltip = "CodexBar\nClaude: 87% left\nCodex: 92% left"; @@ -1070,9 +1075,9 @@ mod tests { } let tooltip = lines.join("\n"); - let fitted = fit_tooltip(&tooltip, TRAY_TOOLTIP_MAX_UTF16); + let fitted = fit_tooltip(&tooltip, WIDE_BUDGET); - assert!(utf16_len(&fitted) <= TRAY_TOOLTIP_MAX_UTF16, "{fitted}"); + assert!(utf16_len(&fitted) <= WIDE_BUDGET, "{fitted}"); let shown: Vec<&str> = fitted.split('\n').collect(); assert_eq!(shown[0], "CodexBar", "{fitted}"); assert!(shown.len() > 2, "{fitted}"); @@ -1090,9 +1095,9 @@ mod tests { let long = "A".repeat(115); let tooltip = format!("CodexBar\n{long}\nB line that is dropped entirely here"); - let fitted = fit_tooltip(&tooltip, TRAY_TOOLTIP_MAX_UTF16); + let fitted = fit_tooltip(&tooltip, WIDE_BUDGET); - assert!(utf16_len(&fitted) <= TRAY_TOOLTIP_MAX_UTF16, "{fitted}"); + assert!(utf16_len(&fitted) <= WIDE_BUDGET, "{fitted}"); assert_eq!(fitted, format!("CodexBar\n{long}…")); } @@ -1106,10 +1111,10 @@ mod tests { ] .join("\n"); - let fitted = fit_tooltip(&tooltip, TRAY_TOOLTIP_MAX_UTF16); + let fitted = fit_tooltip(&tooltip, WIDE_BUDGET); assert_eq!(fitted, "CodexBar\nOpenAI: Major outage\nClaude: 87% left…"); - assert!(utf16_len(&fitted) <= TRAY_TOOLTIP_MAX_UTF16); + assert!(utf16_len(&fitted) <= WIDE_BUDGET); } #[test] @@ -1127,14 +1132,25 @@ mod tests { // Each emoji is two UTF-16 units; a char-count cut would overflow or split a pair. let tooltip = format!("{}\nCodexBar", "🙂".repeat(100)); - let fitted = fit_tooltip(&tooltip, TRAY_TOOLTIP_MAX_UTF16); + let fitted = fit_tooltip(&tooltip, WIDE_BUDGET); - assert!(utf16_len(&fitted) <= TRAY_TOOLTIP_MAX_UTF16, "{fitted}"); + assert!(utf16_len(&fitted) <= WIDE_BUDGET, "{fitted}"); assert_eq!(fitted.chars().filter(|ch| *ch == '🙂').count(), 63); assert!(fitted.ends_with('…'), "{fitted}"); assert!(!fitted.contains('\n'), "{fitted}"); } + #[test] + fn fit_tooltip_keeps_whole_provider_lines_within_the_legacy_tip_limit() { + let tooltip = + "CodexBar\nClaude: 58% left • Resets in 2h 59m\nCodex: 82% left • Resets in 4d 3h"; + + let fitted = fit_tooltip(tooltip, TRAY_TOOLTIP_MAX_UTF16); + + assert!(utf16_len(&fitted) <= 63, "{fitted}"); + assert_eq!(fitted, "CodexBar\nClaude: 58% left • Resets in 2h 59m…"); + } + #[test] fn fit_tooltip_handles_a_first_line_longer_than_the_limit() { let fitted = fit_tooltip(&"중".repeat(200), TRAY_TOOLTIP_MAX_UTF16); From 6f986e40f90e1d76fc71f388e5a77dad780544e4 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 20:31:11 +0700 Subject: [PATCH 4/4] Retrigger CI after runner out-of-memory