fix(tui): localize notifications and persist sidebar focus

This commit is contained in:
CodeWhale Agent
2026-06-12 15:47:44 -07:00
parent e4989c0eae
commit b0577057c9
4 changed files with 212 additions and 94 deletions
+7 -1
View File
@@ -1516,6 +1516,8 @@ pub struct App {
pub sidebar_resize_total_width: u16,
/// Sidebar width changed during this drag and needs persistence.
pub sidebar_width_dirty: bool,
/// Sidebar focus/hidden state changed and needs persistence.
pub sidebar_focus_dirty: bool,
/// Whether the session-context panel is enabled (#504).
pub context_panel: bool,
/// Minimum number of consecutive safe tool cells needed for auto-collapse.
@@ -2300,6 +2302,7 @@ impl App {
last_sidebar_handle_area: None,
sidebar_resize_total_width: 0,
sidebar_width_dirty: false,
sidebar_focus_dirty: false,
context_panel: settings.context_panel,
tool_collapse_threshold: 3,
expanded_tool_runs: HashSet::new(),
@@ -3452,7 +3455,10 @@ impl App {
}
pub fn set_sidebar_focus(&mut self, focus: SidebarFocus) {
self.sidebar_focus = focus;
if self.sidebar_focus != focus {
self.sidebar_focus = focus;
self.sidebar_focus_dirty = true;
}
self.needs_redraw = true;
}
+138 -70
View File
@@ -482,8 +482,8 @@ fn completion_sound_state_for_tests() -> (crate::config::CompletionSound, Option
/// The notification includes:
/// - **Title**: "CodeWhale"
/// - **Subtitle**: First line of `msg` (when the message contains a newline,
/// e.g. the response preview from a completed turn)
/// - **Body**: Remaining lines of `msg`, or the full `msg` if single-line
/// e.g. the localized completion status from a completed turn)
/// - **Body**: Remaining lines of `msg`, if any
/// - **Sound**: Default macOS notification sound
///
/// The message body is capped at 200 **characters** (not bytes) to keep the
@@ -503,7 +503,7 @@ fn completion_sound_state_for_tests() -> (crate::config::CompletionSound, Option
/// swallowed.
#[cfg(target_os = "macos")]
fn macos_display_notification(msg: &str) {
let body = msg.to_string();
let message = msg.to_string();
// Spawn on a background thread so we don't block the caller.
// osascript itself is fast (~50 ms), but spawning a subprocess
@@ -511,53 +511,28 @@ fn macos_display_notification(msg: &str) {
let _ = std::thread::Builder::new()
.name("osascript-notif".into())
.spawn(move || {
// Char-bounded truncation (not byte-bounded) so we don't slice
// through a multi-byte sequence and emit invalid UTF-8.
let body_str: String = body.chars().take(200).collect();
// Build AppleScript that receives the message via ARGV
// instead of inline string interpolation. AppleScript does
// not treat backslash as an escape inside double-quoted
// string literals, so `\"` would terminate the string at
// the `"` and leave a dangling `\`. Passing the message as
// a command-line argument avoids any injection risk.
//
// When the message has multiple lines, the first line
// becomes the subtitle and the rest becomes the body —
// this lets turn notifications show the response preview
// in the subtitle and the duration/cost summary in the body.
let mut args: Vec<String> = Vec::new();
if let Some(idx) = body_str.find('\n') {
let subtitle = body_str[..idx].trim();
let body_text = body_str[idx + 1..].trim();
args.extend_from_slice(&[
"-e".into(),
"on run argv".into(),
"-e".into(),
"set theBody to item 1 of argv".into(),
"-e".into(),
"set theSubtitle to item 2 of argv".into(),
"-e".into(),
"display notification theBody with title \"CodeWhale\" subtitle theSubtitle sound name \"default\"".into(),
"-e".into(),
"end run".into(),
"--".into(),
body_text.into(),
subtitle.into(),
]);
} else {
args.extend_from_slice(&[
"-e".into(),
"on run argv".into(),
"-e".into(),
"display notification (item 1 of argv) with title \"CodeWhale\" sound name \"default\"".into(),
"-e".into(),
"end run".into(),
"--".into(),
body_str,
]);
}
let (subtitle, body) = macos_notification_parts(&message);
let args = [
"-e".to_string(),
"on run argv".to_string(),
"-e".to_string(),
"set theBody to item 1 of argv".to_string(),
"-e".to_string(),
"set theSubtitle to item 2 of argv".to_string(),
"-e".to_string(),
"display notification theBody with title \"CodeWhale\" subtitle theSubtitle sound name \"default\"".to_string(),
"-e".to_string(),
"end run".to_string(),
"--".to_string(),
body,
subtitle,
];
match std::process::Command::new("osascript")
.args(&args)
@@ -575,6 +550,38 @@ fn macos_display_notification(msg: &str) {
});
}
#[cfg(target_os = "macos")]
fn macos_notification_parts(msg: &str) -> (String, String) {
const SUBTITLE_MAX_CHARS: usize = 80;
const BODY_MAX_CHARS: usize = 200;
let sanitized = super::ui::sanitize_stream_chunk(msg);
let lines: Vec<&str> = sanitized
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
if lines.is_empty() {
return ("CodeWhale".to_string(), String::new());
}
let subtitle = truncate_notification_text(lines[0], SUBTITLE_MAX_CHARS);
let body = truncate_notification_text(&lines[1..].join("\n"), BODY_MAX_CHARS);
(subtitle, body)
}
#[cfg(target_os = "macos")]
fn truncate_notification_text(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let take = max_chars.saturating_sub(3);
let mut out = text.chars().take(take).collect::<String>();
out.push_str("...");
out
}
/// Return a human-readable duration string, capped at two units so
/// it stays compact in headers and notifications.
///
@@ -644,6 +651,7 @@ pub fn humanize_duration(d: Duration) -> String {
// *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::localization::Locale;
use crate::models::{ContentBlock, Message};
use crate::tui::app::App;
@@ -700,25 +708,18 @@ pub fn completed_turn_message(
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(|| "codewhale: turn complete".to_string());
let mut msg = completion_status(
notification_turn_complete(app.ui_locale),
include_summary,
turn_elapsed,
turn_cost.map(|cost| crate::pricing::format_cost_estimate(cost, app.cost_currency)),
);
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!("codewhale: turn complete ({human}, {cost})")
}
None => format!("codewhale: turn complete ({human})"),
};
if msg == "codewhale: turn complete" {
msg = summary;
} else {
msg.push('\n');
msg.push_str(&summary);
}
if let Some(preview) =
text_summary(current_streaming_text).or_else(|| latest_assistant_text(&app.api_messages))
{
msg.push('\n');
msg.push_str(&preview);
}
msg
@@ -728,6 +729,7 @@ pub fn completed_turn_message(
/// 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(
locale: Locale,
id: &str,
result: &str,
include_summary: bool,
@@ -737,20 +739,64 @@ pub fn subagent_completion_message(
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with("<codewhale:subagent.done>"));
let mut msg = result_line
let mut msg = completion_status(
notification_subagent_complete(locale),
include_summary,
elapsed,
None,
);
let detail = result_line
.and_then(text_summary)
.map(|summary| format!("sub-agent {id}: {summary}"))
.unwrap_or_else(|| format!("codewhale: sub-agent {id} complete"));
.map(|summary| format!("{id}: {summary}"))
.unwrap_or_else(|| id.to_string());
if include_summary {
let human = humanize_duration(elapsed);
msg.push('\n');
msg.push_str(&format!("codewhale: sub-agent complete ({human})"));
}
msg.push('\n');
msg.push_str(&detail);
msg
}
fn completion_status(
label: &str,
include_summary: bool,
elapsed: Duration,
cost: Option<String>,
) -> String {
if !include_summary {
return label.to_string();
}
let human = humanize_duration(elapsed);
match cost {
Some(cost) => format!("{label} ({human}, {cost})"),
None => format!("{label} ({human})"),
}
}
fn notification_turn_complete(locale: Locale) -> &'static str {
match locale {
Locale::En => "Turn complete",
Locale::Ja => "ターン完了",
Locale::ZhHans => "本轮已完成",
Locale::ZhHant => "本輪已完成",
Locale::PtBr => "Turno concluído",
Locale::Es419 => "Turno completado",
Locale::Vi => "Lượt hoàn tất",
}
}
fn notification_subagent_complete(locale: Locale) -> &'static str {
match locale {
Locale::En => "Sub-agent complete",
Locale::Ja => "サブエージェント完了",
Locale::ZhHans => "子代理已完成",
Locale::ZhHant => "子代理已完成",
Locale::PtBr => "Subagente concluído",
Locale::Es419 => "Subagente completado",
Locale::Vi => "Sub-agent hoàn tất",
}
}
/// 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
@@ -907,6 +953,28 @@ mod tests {
assert!(!out.is_empty());
}
#[cfg(target_os = "macos")]
#[test]
fn macos_notification_keeps_localized_status_as_subtitle() {
let (subtitle, body) = macos_notification_parts("ターン完了 (1m 5s)\n完了しました。");
assert_eq!(subtitle, "ターン完了 (1m 5s)");
assert_eq!(body, "完了しました。");
}
#[cfg(target_os = "macos")]
#[test]
fn macos_notification_truncates_body_after_status_line() {
let msg = format!("Turn complete\n{}", "assistant preview ".repeat(40));
let (subtitle, body) = macos_notification_parts(&msg);
assert_eq!(subtitle, "Turn complete");
assert!(body.starts_with("assistant preview"));
assert!(body.ends_with("..."));
assert_eq!(body.chars().count(), 200);
}
#[test]
fn tmux_dcs_passthrough_wraps_osc9() {
let out = capture(Method::Osc9, true, "hello", 0, 1);
+24 -16
View File
@@ -2401,6 +2401,7 @@ async fn run_event_loop(
{
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
let msg = notifications::subagent_completion_message(
app.ui_locale,
&id,
&result,
include_summary,
@@ -3017,14 +3018,7 @@ async fn run_event_loop(
{
return Ok(());
}
// Persist sidebar width when the user finishes a drag-to-resize.
if app.sidebar_width_dirty {
app.sidebar_width_dirty = false;
if let Ok(mut settings) = Settings::load() {
settings.update_sidebar_width(app.sidebar_width_percent);
let _ = settings.save();
}
}
persist_sidebar_settings_if_dirty(app);
continue;
}
@@ -3447,14 +3441,7 @@ async fn run_event_loop(
{
return Ok(());
}
// Persist sidebar width when the user finishes a drag-to-resize.
if app.sidebar_width_dirty {
app.sidebar_width_dirty = false;
if let Ok(mut settings) = Settings::load() {
settings.update_sidebar_width(app.sidebar_width_percent);
let _ = settings.save();
}
}
persist_sidebar_settings_if_dirty(app);
continue;
}
@@ -4608,6 +4595,27 @@ fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) {
app.status_message = Some("Sidebar focus: agents".to_string());
}
fn persist_sidebar_settings_if_dirty(app: &mut App) {
if !app.sidebar_width_dirty && !app.sidebar_focus_dirty {
return;
}
let width_dirty = app.sidebar_width_dirty;
let focus_dirty = app.sidebar_focus_dirty;
app.sidebar_width_dirty = false;
app.sidebar_focus_dirty = false;
if let Ok(mut settings) = Settings::load() {
if width_dirty {
settings.update_sidebar_width(app.sidebar_width_percent);
}
if focus_dirty {
let _ = settings.set("sidebar_focus", app.sidebar_focus.as_setting());
}
let _ = settings.save();
}
}
fn apply_alt_0_shortcut(app: &mut App, modifiers: KeyModifiers) {
if modifiers.contains(KeyModifiers::CONTROL) {
if app.sidebar_focus == SidebarFocus::Hidden {
+43 -7
View File
@@ -3499,6 +3499,7 @@ fn ctrl_alt_0_hides_sidebar() {
apply_alt_0_shortcut(&mut app, KeyModifiers::ALT | KeyModifiers::CONTROL);
assert_eq!(app.sidebar_focus, SidebarFocus::Hidden);
assert!(app.sidebar_focus_dirty);
assert_eq!(app.status_message.as_deref(), Some("Sidebar hidden"));
}
@@ -3513,6 +3514,20 @@ fn ctrl_alt_0_restores_auto_sidebar_when_already_hidden() {
assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: auto"));
}
#[test]
fn sidebar_focus_dirty_persists_saved_focus() {
let _guard = ConfigPathEnvGuard::new();
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Hidden;
app.sidebar_focus_dirty = true;
persist_sidebar_settings_if_dirty(&mut app);
assert!(!app.sidebar_focus_dirty);
let settings = crate::settings::Settings::load().expect("load settings");
assert_eq!(settings.sidebar_focus, "hidden");
}
#[test]
fn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {
let mut app = create_test_app();
@@ -9421,7 +9436,7 @@ fn completed_turn_notification_uses_streaming_text() {
Duration::from_secs(12),
None,
);
assert_eq!(msg, "Hello there.\nWhat's next?");
assert_eq!(msg, "Turn complete\nHello there.\nWhat's next?");
}
#[test]
@@ -9456,7 +9471,7 @@ fn completed_turn_notification_falls_back_to_latest_assistant_message() {
Duration::from_secs(75),
None,
);
assert_eq!(msg, "Latest reply");
assert_eq!(msg, "Turn complete\nLatest reply");
}
#[test]
@@ -9469,7 +9484,7 @@ fn completed_turn_notification_falls_back_to_default_when_empty() {
Duration::from_secs(5),
None,
);
assert_eq!(msg, "codewhale: turn complete");
assert_eq!(msg, "Turn complete");
}
#[test]
@@ -9484,34 +9499,55 @@ fn completed_turn_notification_truncates_long_text() {
None,
);
assert!(msg.ends_with("..."));
let preview = msg
.strip_prefix("Turn complete\n")
.expect("notification should lead with completion status");
// 360-char body + 3-char ellipsis
assert_eq!(msg.chars().count(), 363);
assert_eq!(preview.chars().count(), 363);
}
#[test]
fn completed_turn_notification_leads_with_user_locale() {
let mut app = create_test_app();
app.ui_locale = crate::localization::Locale::Ja;
let msg = crate::tui::notifications::completed_turn_message(
&app,
"完了しました。",
true,
Duration::from_secs(65),
None,
);
assert_eq!(msg, "ターン完了 (1m 5s)\n完了しました。");
}
#[test]
fn subagent_completion_notification_uses_summary_line_not_sentinel() {
let msg = crate::tui::notifications::subagent_completion_message(
crate::localization::Locale::En,
"agent_live",
"Finished the docs audit.\n<codewhale:subagent.done>{}</codewhale:subagent.done>",
false,
Duration::from_secs(42),
);
assert_eq!(msg, "sub-agent agent_live: Finished the docs audit.");
assert_eq!(
msg,
"Sub-agent complete\nagent_live: Finished the docs audit."
);
assert!(!msg.contains("codewhale:subagent.done"));
}
#[test]
fn subagent_completion_notification_can_include_elapsed_summary() {
let msg = crate::tui::notifications::subagent_completion_message(
crate::localization::Locale::En,
"agent_live",
"",
true,
Duration::from_secs(65),
);
assert!(msg.contains("codewhale: sub-agent agent_live complete"));
assert!(msg.contains("codewhale: sub-agent complete (1m 5s)"));
assert_eq!(msg, "Sub-agent complete (1m 5s)\nagent_live");
}
#[test]