diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index f2f950bb..1355c762 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -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; } diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index cfa0f8f6..9efdf61a 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -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 = 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::(); + 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, ) -> 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("")); - 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 { + 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); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 68a83668..96c1847e 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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 { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index a6d31548..921fc2bb 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -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{}", 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]