From 65ac94caa3bcd4a7c0f492b2abd8aa303f13dde1 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Tue, 2 Jun 2026 10:34:33 +0800 Subject: [PATCH 1/5] feat(i18n): add FanoutCounts MessageId, wire into FanoutCard stats line --- crates/tui/src/localization.rs | 24 ++++++++++++ crates/tui/src/tui/subagent_routing.rs | 5 ++- crates/tui/src/tui/views/mod.rs | 2 +- crates/tui/src/tui/widgets/agent_card.rs | 47 +++++++++++++++++++----- 4 files changed, 66 insertions(+), 12 deletions(-) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 5e67b571e..8772e49aa 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -488,6 +488,8 @@ pub enum MessageId { CtxMenuContextInspectorDesc, CtxMenuHelp, CtxMenuHelpDesc, + // Agent fanout card. + FanoutCounts, } #[allow(dead_code)] @@ -752,6 +754,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CtxMenuContextInspectorDesc, MessageId::CtxMenuHelp, MessageId::CtxMenuHelpDesc, + MessageId::FanoutCounts, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -1319,6 +1322,9 @@ fn english(id: MessageId) -> &'static str { MessageId::CtxMenuContextInspectorDesc => "active context and cache hints", MessageId::CtxMenuHelp => "Help", MessageId::CtxMenuHelpDesc => "keybindings and commands", + MessageId::FanoutCounts => { + "{done} done · {running} running · {failed} failed · {pending} pending" + } } } @@ -1754,6 +1760,9 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "ngữ cảnh đang hoạt động và gợi ý bộ nhớ đệm", MessageId::CtxMenuHelp => "Trợ giúp", MessageId::CtxMenuHelpDesc => "phím tắt và lệnh", + MessageId::FanoutCounts => { + "{done} hoàn thành · {running} đang chạy · {failed} thất bại · {pending} chờ" + } }) } @@ -1767,6 +1776,9 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { MessageId::TranslationComplete => "翻譯完成", MessageId::TranslationFailed => "翻譯失敗", MessageId::FooterBalancePrefix => "餘額", + MessageId::FanoutCounts => { + "{done} 已完成 · {running} 運行中 · {failed} 失敗 · {pending} 等待中" + } other => chinese_simplified(other)?, }) } @@ -2163,6 +2175,9 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "アクティブなコンテキストとキャッシュヒント", MessageId::CtxMenuHelp => "ヘルプ", MessageId::CtxMenuHelpDesc => "キー操作とコマンド", + MessageId::FanoutCounts => { + "{done} 完了 · {running} 実行中 · {failed} 失敗 · {pending} 待機" + } }) } @@ -2500,6 +2515,9 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "活动上下文和缓存提示", MessageId::CtxMenuHelp => "帮助", MessageId::CtxMenuHelpDesc => "快捷键和命令", + MessageId::FanoutCounts => { + "{done} 已完成 · {running} 运行中 · {failed} 失败 · {pending} 等待中" + } }) } @@ -2919,6 +2937,9 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "contexto ativo e dicas de cache", MessageId::CtxMenuHelp => "Ajuda", MessageId::CtxMenuHelpDesc => "atalhos de teclado e comandos", + MessageId::FanoutCounts => { + "{done} concluído · {running} executando · {failed} falhou · {pending} pendente" + } }) } @@ -3346,6 +3367,9 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CtxMenuContextInspectorDesc => "contexto activo y sugerencias de caché", MessageId::CtxMenuHelp => "Ayuda", MessageId::CtxMenuHelpDesc => "atajos de teclado y comandos", + MessageId::FanoutCounts => { + "{done} completado · {running} ejecutando · {failed} falló · {pending} pendiente" + } }) } diff --git a/crates/tui/src/tui/subagent_routing.rs b/crates/tui/src/tui/subagent_routing.rs index 94c9e9751..afe48361c 100644 --- a/crates/tui/src/tui/subagent_routing.rs +++ b/crates/tui/src/tui/subagent_routing.rs @@ -154,7 +154,10 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox card.claim_pending_worker(&agent_id, AgentLifecycle::Running); app.subagent_card_index.insert(agent_id, idx); } else { - let mut card = FanoutCard::new(dispatch_kind.unwrap_or("rlm_eval").to_string()); + let mut card = FanoutCard::new( + dispatch_kind.unwrap_or("rlm_eval").to_string(), + app.ui_locale, + ); card.upsert_worker(&agent_id, AgentLifecycle::Running); app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); let idx = app.history.len().saturating_sub(1); diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 2f79796f5..360a65d99 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -2203,7 +2203,7 @@ mod tests { #[test] fn subagent_view_agents_includes_live_fanout_workers_when_cache_is_empty() { let mut app = create_test_app(); - let mut card = FanoutCard::new("rlm").with_workers(["chunk_1", "chunk_2"]); + let mut card = FanoutCard::new("rlm", app.ui_locale).with_workers(["chunk_1", "chunk_2"]); card.upsert_worker("chunk_1", AgentLifecycle::Completed); card.upsert_worker("chunk_2", AgentLifecycle::Running); app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); diff --git a/crates/tui/src/tui/widgets/agent_card.rs b/crates/tui/src/tui/widgets/agent_card.rs index 5b7098a0e..7765780d6 100644 --- a/crates/tui/src/tui/widgets/agent_card.rs +++ b/crates/tui/src/tui/widgets/agent_card.rs @@ -17,6 +17,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; +use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::tools::subagent::MailboxMessage; use crate::tui::widgets::tool_card::{ToolFamily, family_glyph, family_label}; @@ -193,14 +194,16 @@ impl WorkerSlot { pub struct FanoutCard { pub kind: String, pub workers: Vec, + pub locale: Locale, } impl FanoutCard { #[must_use] - pub fn new(kind: impl Into) -> Self { + pub fn new(kind: impl Into, locale: Locale) -> Self { Self { kind: kind.into(), workers: Vec::new(), + locale, } } @@ -309,9 +312,11 @@ impl FanoutCard { lines.push(Line::from(vec![ Span::styled(" ", Style::default()), Span::styled( - format!( - "{done} done \u{00B7} {running} running \u{00B7} {failed} failed \u{00B7} {pending} pending" - ), + tr(self.locale, MessageId::FanoutCounts) + .replace("{done}", &done.to_string()) + .replace("{running}", &running.to_string()) + .replace("{failed}", &failed.to_string()) + .replace("{pending}", &pending.to_string()), Style::default().fg(palette::TEXT_MUTED), ), ])); @@ -632,7 +637,7 @@ mod tests { #[test] fn fanout_card_dot_grid_renders_stateful_worker_slots() { - let mut card = FanoutCard::new("fanout") + let mut card = FanoutCard::new("fanout", Locale::En) .with_workers(["w_1", "w_2", "w_3", "w_4", "w_5", "w_6", "w_7"]); card.upsert_worker("w_1", AgentLifecycle::Completed); card.upsert_worker("w_2", AgentLifecycle::Completed); @@ -649,7 +654,8 @@ mod tests { #[test] fn fanout_card_aggregate_counts_match_dot_grid() { - let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2", "w_3", "w_4"]); + let mut card = + FanoutCard::new("rlm", Locale::En).with_workers(["w_1", "w_2", "w_3", "w_4"]); card.upsert_worker("w_1", AgentLifecycle::Completed); card.upsert_worker("w_2", AgentLifecycle::Completed); card.upsert_worker("w_3", AgentLifecycle::Completed); @@ -672,7 +678,7 @@ mod tests { #[test] fn fanout_apply_inserts_unknown_worker_via_child_spawned() { - let mut card = FanoutCard::new("fanout"); + let mut card = FanoutCard::new("fanout", Locale::En); let msg = MailboxMessage::ChildSpawned { parent_id: "root".into(), child_id: "agent_late".into(), @@ -685,7 +691,7 @@ mod tests { #[test] fn fanout_started_claims_seeded_pending_slot_without_growing_grid() { - let mut card = FanoutCard::new("fanout").with_workers(["task:a", "task:b"]); + let mut card = FanoutCard::new("fanout", Locale::En).with_workers(["task:a", "task:b"]); let started = MailboxMessage::started("agent_live", crate::tools::subagent::SubAgentType::General); @@ -700,7 +706,7 @@ mod tests { #[test] fn fanout_apply_transitions_worker_through_lifecycle() { - let mut card = FanoutCard::new("fanout").with_workers(["w_1"]); + let mut card = FanoutCard::new("fanout", Locale::En).with_workers(["w_1"]); let started = MailboxMessage::started("w_1", crate::tools::subagent::SubAgentType::General); apply_to_fanout(&mut card, &started); assert_eq!(card.workers[0].status, AgentLifecycle::Running); @@ -729,7 +735,7 @@ mod tests { ]; for (total, done, expected) in cases { let ids: Vec = (0..*total).map(|i| format!("w_{i}")).collect(); - let mut card = FanoutCard::new("fanout").with_workers(ids.iter().cloned()); + let mut card = FanoutCard::new("fanout", Locale::En).with_workers(ids.iter().cloned()); for id in ids.iter().take(*done) { card.upsert_worker(id, AgentLifecycle::Completed); } @@ -740,4 +746,25 @@ mod tests { ); } } + + #[test] + fn fanout_counts_are_localized() { + let ids: Vec = (0..16).map(|i| format!("w_{i}")).collect(); + let mut card = FanoutCard::new("fanout", Locale::ZhHans).with_workers(ids.iter().cloned()); + for id in ids.iter().take(12) { + card.upsert_worker(id, AgentLifecycle::Completed); + } + card.upsert_worker("w_12", AgentLifecycle::Running); + // w_13..w_15 stay Pending; 0 failed + + let rendered = render_to_strings(&card.render_lines(80)); + let stats = rendered + .iter() + .find(|line| line.contains('·')) + .expect("counts line present"); + assert!(stats.contains("已完成"), "{stats}"); + assert!(stats.contains("运行中"), "{stats}"); + assert!(stats.contains("失败"), "{stats}"); + assert!(stats.contains("等待中"), "{stats}"); + } } From f49416301c2fb72f89e264c7ff5686383dea1fe9 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Tue, 2 Jun 2026 10:48:23 +0800 Subject: [PATCH 2/5] fix: avoid Instant overflow in turn_liveness tests on Windows --- crates/tui/src/tui/ui/tests.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 166031371..d1af0c301 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2512,11 +2512,15 @@ fn turn_liveness_leaves_active_turn_running() { #[test] fn turn_liveness_uses_recent_turn_activity_not_turn_start() { let mut app = create_test_app(); - let now = Instant::now(); app.is_loading = true; app.runtime_turn_status = Some("in_progress".to_string()); - app.turn_started_at = Some(now - TURN_STALL_WATCHDOG_TIMEOUT - Duration::from_secs(30)); - app.turn_last_activity_at = Some(now - Duration::from_secs(1)); + app.turn_started_at = Some(Instant::now()); + app.turn_last_activity_at = Some( + app.turn_started_at.unwrap() + + TURN_STALL_WATCHDOG_TIMEOUT + + Duration::from_secs(29), + ); + let now = app.turn_last_activity_at.unwrap() + Duration::from_secs(1); let recovered = reconcile_turn_liveness(&mut app, now, false); @@ -2529,11 +2533,14 @@ fn turn_liveness_uses_recent_turn_activity_not_turn_start() { #[test] fn turn_liveness_does_not_abort_running_tool() { let mut app = create_test_app(); - let now = Instant::now(); app.is_loading = true; app.runtime_turn_status = Some("in_progress".to_string()); - app.turn_started_at = Some(now - TURN_STALL_WATCHDOG_TIMEOUT - Duration::from_secs(30)); + app.turn_started_at = Some(Instant::now()); app.turn_last_activity_at = app.turn_started_at; + let now = app.turn_started_at.unwrap() + + TURN_STALL_WATCHDOG_TIMEOUT + + Duration::from_secs(30) + + Duration::from_secs(1); let mut active = ActiveCell::new(); active.push_tool( "tool-1", From fedb5a3c8b93610dee417707e112e2947a29a4b3 Mon Sep 17 00:00:00 2001 From: gordonlu Date: Tue, 2 Jun 2026 11:08:24 +0800 Subject: [PATCH 3/5] fmt: cargo fmt --all --- crates/tui/src/tui/ui/tests.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index d1af0c301..eb727e589 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -2515,11 +2515,8 @@ fn turn_liveness_uses_recent_turn_activity_not_turn_start() { app.is_loading = true; app.runtime_turn_status = Some("in_progress".to_string()); app.turn_started_at = Some(Instant::now()); - app.turn_last_activity_at = Some( - app.turn_started_at.unwrap() - + TURN_STALL_WATCHDOG_TIMEOUT - + Duration::from_secs(29), - ); + app.turn_last_activity_at = + Some(app.turn_started_at.unwrap() + TURN_STALL_WATCHDOG_TIMEOUT + Duration::from_secs(29)); let now = app.turn_last_activity_at.unwrap() + Duration::from_secs(1); let recovered = reconcile_turn_liveness(&mut app, now, false); From 6bc5e629c89aa98762b3f181ade29fefaa77380f Mon Sep 17 00:00:00 2001 From: gordonlu Date: Tue, 2 Jun 2026 11:10:05 +0800 Subject: [PATCH 4/5] fix: restore two-line draft header layout --- crates/tui/src/commands/queue.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/commands/queue.rs b/crates/tui/src/commands/queue.rs index b1c76b8b6..8784c61cd 100644 --- a/crates/tui/src/commands/queue.rs +++ b/crates/tui/src/commands/queue.rs @@ -28,7 +28,7 @@ fn list_queue(app: &mut App) -> CommandResult { let queued = app.queued_message_count(); if let Some(draft) = app.queued_draft.as_ref() { - lines.push("Editing queued message:".to_string()); + lines.push(tr(locale, MessageId::CmdQueueDraftHeader).to_string()); lines.push(format!("- {}", truncate_preview(&draft.display))); } From 851170ad9a9b8ec042eba2c0d6efbf8c4f470bee Mon Sep 17 00:00:00 2001 From: gordonlu Date: Tue, 2 Jun 2026 11:10:18 +0800 Subject: [PATCH 5/5] Revert "fix: restore two-line draft header layout" This reverts commit 6bc5e629c89aa98762b3f181ade29fefaa77380f. --- crates/tui/src/commands/queue.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/commands/queue.rs b/crates/tui/src/commands/queue.rs index 8784c61cd..b1c76b8b6 100644 --- a/crates/tui/src/commands/queue.rs +++ b/crates/tui/src/commands/queue.rs @@ -28,7 +28,7 @@ fn list_queue(app: &mut App) -> CommandResult { let queued = app.queued_message_count(); if let Some(draft) = app.queued_draft.as_ref() { - lines.push(tr(locale, MessageId::CmdQueueDraftHeader).to_string()); + lines.push("Editing queued message:".to_string()); lines.push(format!("- {}", truncate_preview(&draft.display))); }