fix(tui): localize notifications and persist sidebar focus
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user