From e5f16fd7db0de498e191c81da1e6f4863708c5d9 Mon Sep 17 00:00:00 2001 From: yishuiliunian Date: Wed, 3 Jun 2026 09:30:29 +0800 Subject: [PATCH 1/2] fix(tui): show "Compacting" status during compaction instead of Idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #190 made AgentStatus backend-only and declared compact_banner the sole signal for compacting, but the unified status line never consumed it. Manual /compact runs as a control command in the idle phase, so status stays WaitingForInput and the decision tree fell through to Idle with a frozen spinner — covering auto-compaction and resume-rehydrate too. Extract the status-label decision into a pure pick_label() and add a Compacting tier (after Thinking, before Streaming). Feed it compact_banner.is_some(), and add the same flag to is_agent_active so the spinner animates for the whole compaction rather than freezing after the 750ms activity grace. Presentation-layer only: no AgentStatus mutation, no protocol change, so it does not reintroduce the #189/#190 turn-lifecycle desync risk. --- crates/loopal-tui/src/views/mod.rs | 1 + crates/loopal-tui/src/views/unified_status.rs | 32 +++--- .../src/views/unified_status_label.rs | 106 ++++++++++++++++++ 3 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 crates/loopal-tui/src/views/unified_status_label.rs diff --git a/crates/loopal-tui/src/views/mod.rs b/crates/loopal-tui/src/views/mod.rs index a342da68..720c95b6 100644 --- a/crates/loopal-tui/src/views/mod.rs +++ b/crates/loopal-tui/src/views/mod.rs @@ -30,6 +30,7 @@ pub mod text_width; pub mod topology_overlay; pub mod unified_status; mod unified_status_goal; +mod unified_status_label; /// Shared dim-grey color used for separators and inactive panel decoration. pub const DIM_SEPARATOR: ratatui::style::Color = ratatui::style::Color::Rgb(60, 60, 60); diff --git a/crates/loopal-tui/src/views/unified_status.rs b/crates/loopal-tui/src/views/unified_status.rs index cdcf35bc..92e19f18 100644 --- a/crates/loopal-tui/src/views/unified_status.rs +++ b/crates/loopal-tui/src/views/unified_status.rs @@ -8,6 +8,7 @@ use loopal_session::state::SessionState; use loopal_view_state::AgentConversation; use super::unified_status_goal::append_goal_indicator; +use super::unified_status_label::{ActivityInputs, pick_label}; use crate::app::App; pub const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -99,22 +100,22 @@ fn status_icon_and_label( elapsed: std::time::Duration, is_active: bool, ) -> (String, Style, &'static str) { - let spin = || spinner_frame(elapsed).to_string(); - if conv.thinking_active { - (spin(), Style::default().fg(Color::Magenta), "Thinking") - } else if !conv.streaming_text.is_empty() { - (spin(), Style::default().fg(Color::Green), "Streaming") - } else if conv.pending_permission.is_some() { - ("●".into(), Style::default().fg(Color::Yellow), "Waiting") - } else if !active_agent_idle(app, state) { - (spin(), Style::default().fg(Color::Cyan), "Working") - } else if has_live_subagents(app) { - (spin(), Style::default().fg(Color::Blue), "Agents") - } else if is_active { - (spin(), Style::default().fg(Color::Cyan), "Working") + let inputs = ActivityInputs { + thinking: conv.thinking_active, + compacting: conv.compact_banner.is_some(), + streaming: !conv.streaming_text.is_empty(), + pending_permission: conv.pending_permission.is_some(), + agent_idle: active_agent_idle(app, state), + has_subagents: has_live_subagents(app), + recently_or_active: is_active, + }; + let (use_spinner, color, label) = pick_label(&inputs); + let icon = if use_spinner { + spinner_frame(elapsed).to_string() } else { - ("●".into(), Style::default().fg(Color::DarkGray), "Idle") - } + "●".to_string() + }; + (icon, Style::default().fg(color), label) } pub fn spinner_frame(elapsed: std::time::Duration) -> &'static str { @@ -126,6 +127,7 @@ fn is_agent_active(app: &App, state: &SessionState, conv: &AgentConversation) -> !active_agent_idle(app, state) || !conv.streaming_text.is_empty() || conv.thinking_active + || conv.compact_banner.is_some() || has_live_subagents(app) || conv.is_recently_active(ACTIVITY_GRACE) } diff --git a/crates/loopal-tui/src/views/unified_status_label.rs b/crates/loopal-tui/src/views/unified_status_label.rs new file mode 100644 index 00000000..c53ef871 --- /dev/null +++ b/crates/loopal-tui/src/views/unified_status_label.rs @@ -0,0 +1,106 @@ +use ratatui::style::Color; + +pub(crate) struct ActivityInputs { + pub thinking: bool, + pub compacting: bool, + pub streaming: bool, + pub pending_permission: bool, + pub agent_idle: bool, + pub has_subagents: bool, + pub recently_or_active: bool, +} + +pub(crate) fn pick_label(i: &ActivityInputs) -> (bool, Color, &'static str) { + if i.thinking { + (true, Color::Magenta, "Thinking") + } else if i.compacting { + (true, Color::Cyan, "Compacting") + } else if i.streaming { + (true, Color::Green, "Streaming") + } else if i.pending_permission { + (false, Color::Yellow, "Waiting") + } else if !i.agent_idle { + (true, Color::Cyan, "Working") + } else if i.has_subagents { + (true, Color::Blue, "Agents") + } else if i.recently_or_active { + (true, Color::Cyan, "Working") + } else { + (false, Color::DarkGray, "Idle") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base() -> ActivityInputs { + ActivityInputs { + thinking: false, + compacting: false, + streaming: false, + pending_permission: false, + agent_idle: true, + has_subagents: false, + recently_or_active: false, + } + } + + #[test] + fn idle_when_nothing_active() { + let (spin, color, label) = pick_label(&base()); + assert_eq!((spin, color, label), (false, Color::DarkGray, "Idle")); + } + + #[test] + fn compacting_when_banner_present_and_agent_idle() { + let i = ActivityInputs { compacting: true, ..base() }; + let (spin, color, label) = pick_label(&i); + assert_eq!(label, "Compacting"); + assert_eq!(color, Color::Cyan); + assert!(spin, "compacting must animate the spinner"); + } + + #[test] + fn thinking_outranks_compacting() { + let i = ActivityInputs { thinking: true, compacting: true, ..base() }; + assert_eq!(pick_label(&i).2, "Thinking"); + } + + #[test] + fn compacting_outranks_streaming() { + let i = ActivityInputs { compacting: true, streaming: true, ..base() }; + assert_eq!(pick_label(&i).2, "Compacting"); + } + + #[test] + fn streaming_when_only_streaming() { + let i = ActivityInputs { streaming: true, ..base() }; + assert_eq!(pick_label(&i).2, "Streaming"); + } + + #[test] + fn waiting_uses_dot_not_spinner() { + let i = ActivityInputs { pending_permission: true, ..base() }; + let (spin, color, label) = pick_label(&i); + assert_eq!((spin, color, label), (false, Color::Yellow, "Waiting")); + } + + #[test] + fn working_when_backend_not_idle() { + let i = ActivityInputs { agent_idle: false, ..base() }; + assert_eq!(pick_label(&i).2, "Working"); + } + + #[test] + fn agents_when_subagents_live() { + let i = ActivityInputs { has_subagents: true, ..base() }; + assert_eq!(pick_label(&i).2, "Agents"); + } + + #[test] + fn working_during_activity_grace() { + let i = ActivityInputs { recently_or_active: true, ..base() }; + assert_eq!(pick_label(&i).2, "Working"); + } +} From ba03d11d62767c656aeddd22347c48e834d4c3e9 Mon Sep 17 00:00:00 2001 From: yishuiliunian Date: Wed, 3 Jun 2026 09:41:11 +0800 Subject: [PATCH 2/2] fix: address CI failure - rustfmt struct-update layout in tests rustfmt expands single-line `ActivityInputs { field: x, ..base() }` to multi-line; apply the canonical formatting. --- .../src/views/unified_status_label.rs | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/loopal-tui/src/views/unified_status_label.rs b/crates/loopal-tui/src/views/unified_status_label.rs index c53ef871..9ed64244 100644 --- a/crates/loopal-tui/src/views/unified_status_label.rs +++ b/crates/loopal-tui/src/views/unified_status_label.rs @@ -54,7 +54,10 @@ mod tests { #[test] fn compacting_when_banner_present_and_agent_idle() { - let i = ActivityInputs { compacting: true, ..base() }; + let i = ActivityInputs { + compacting: true, + ..base() + }; let (spin, color, label) = pick_label(&i); assert_eq!(label, "Compacting"); assert_eq!(color, Color::Cyan); @@ -63,44 +66,67 @@ mod tests { #[test] fn thinking_outranks_compacting() { - let i = ActivityInputs { thinking: true, compacting: true, ..base() }; + let i = ActivityInputs { + thinking: true, + compacting: true, + ..base() + }; assert_eq!(pick_label(&i).2, "Thinking"); } #[test] fn compacting_outranks_streaming() { - let i = ActivityInputs { compacting: true, streaming: true, ..base() }; + let i = ActivityInputs { + compacting: true, + streaming: true, + ..base() + }; assert_eq!(pick_label(&i).2, "Compacting"); } #[test] fn streaming_when_only_streaming() { - let i = ActivityInputs { streaming: true, ..base() }; + let i = ActivityInputs { + streaming: true, + ..base() + }; assert_eq!(pick_label(&i).2, "Streaming"); } #[test] fn waiting_uses_dot_not_spinner() { - let i = ActivityInputs { pending_permission: true, ..base() }; + let i = ActivityInputs { + pending_permission: true, + ..base() + }; let (spin, color, label) = pick_label(&i); assert_eq!((spin, color, label), (false, Color::Yellow, "Waiting")); } #[test] fn working_when_backend_not_idle() { - let i = ActivityInputs { agent_idle: false, ..base() }; + let i = ActivityInputs { + agent_idle: false, + ..base() + }; assert_eq!(pick_label(&i).2, "Working"); } #[test] fn agents_when_subagents_live() { - let i = ActivityInputs { has_subagents: true, ..base() }; + let i = ActivityInputs { + has_subagents: true, + ..base() + }; assert_eq!(pick_label(&i).2, "Agents"); } #[test] fn working_during_activity_grace() { - let i = ActivityInputs { recently_or_active: true, ..base() }; + let i = ActivityInputs { + recently_or_active: true, + ..base() + }; assert_eq!(pick_label(&i).2, "Working"); } }