Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions crates/loopal-session/src/agent_conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ pub struct AgentConversation {
// Turn timer
turn_start: Option<Instant>,
last_turn_duration: Duration,
/// Last time the agent emitted a "still working" signal (Stream, tool
/// event, Running, …). Used by the TUI to bridge short idle windows
/// between `AwaitingInput` and the next `Running` event, so the status
/// spinner does not flicker off between turns.
last_active_at: Option<Instant>,
}

impl AgentConversation {
Expand All @@ -53,6 +58,21 @@ impl AgentConversation {
}
}

/// Record that the agent just emitted an activity signal.
///
/// The TUI uses this timestamp to keep the status spinner/timer live
/// during the brief gap between `AwaitingInput` (end of turn N) and
/// `Running` (start of turn N+1), which can be several milliseconds
/// because those events hop across agent-proc → hub → TUI IPC.
pub fn mark_active(&mut self) {
self.last_active_at = Some(Instant::now());
}

/// Whether the agent emitted any activity within the last `grace` window.
pub fn is_recently_active(&self, grace: Duration) -> bool {
self.last_active_at.is_some_and(|t| t.elapsed() < grace)
}

/// Mark the end of a turn (agent became idle).
pub fn end_turn(&mut self) {
if let Some(start) = self.turn_start.take() {
Expand All @@ -64,6 +84,7 @@ impl AgentConversation {
pub fn reset_timer(&mut self) {
self.turn_start = None;
self.last_turn_duration = Duration::ZERO;
self.last_active_at = None;
}

/// Flush buffered streaming text and thinking into SessionMessages.
Expand Down
16 changes: 15 additions & 1 deletion crates/loopal-session/src/agent_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,19 @@ pub(crate) fn apply_agent_event(state: &mut SessionState, name: &str, payload: A
match payload {
AgentEventPayload::Stream { text } => {
conv.begin_turn();
conv.mark_active();
conv.streaming_text.push_str(&text);
obs.status = AgentStatus::Running;
}
AgentEventPayload::ThinkingStream { text } => {
conv.begin_turn();
conv.mark_active();
conv.thinking_active = true;
conv.streaming_thinking.push_str(&text);
obs.status = AgentStatus::Running;
}
AgentEventPayload::ThinkingComplete { token_count } => {
conv.mark_active();
handle_thinking_complete(conv, token_count);
}
AgentEventPayload::ToolCall {
Expand All @@ -48,19 +51,23 @@ pub(crate) fn apply_agent_event(state: &mut SessionState, name: &str, payload: A
obs.tools_in_flight += 1;
obs.last_tool = Some(extract_key_param(&tn, &input));
obs.status = AgentStatus::Running;
conv.mark_active();
handle_tool_call(conv, id, tn, input);
sync_parent = true;
}
payload @ AgentEventPayload::ToolResult { .. } => {
conv.mark_active();
apply_tool_result_event(conv, obs, payload);
sync_parent = true;
}
AgentEventPayload::ToolBatchStart { tool_ids } => {
conv.mark_active();
handle_tool_batch_start(conv, tool_ids);
}
AgentEventPayload::ToolProgress {
id, output_tail, ..
} => {
conv.mark_active();
handle_tool_progress(conv, id, output_tail);
sync_parent = true;
}
Expand All @@ -78,6 +85,7 @@ pub(crate) fn apply_agent_event(state: &mut SessionState, name: &str, payload: A
} => {
conv.retry_banner = Some(format!("{message} ({attempt}/{max_attempts})"));
obs.status = AgentStatus::Running;
conv.mark_active();
}
AgentEventPayload::RetryCleared => conv.retry_banner = None,
AgentEventPayload::AwaitingInput => {
Expand All @@ -101,23 +109,29 @@ pub(crate) fn apply_agent_event(state: &mut SessionState, name: &str, payload: A
crate::rewind::truncate_display_to_turn(conv, remaining_turns);
}
payload @ AgentEventPayload::Compacted { .. } => apply_compaction_event(conv, payload),
AgentEventPayload::Started => obs.status = AgentStatus::Running,
AgentEventPayload::Started => {
obs.status = AgentStatus::Running;
conv.mark_active();
}
AgentEventPayload::Running => {
conv.begin_turn();
conv.mark_active();
obs.status = AgentStatus::Running;
}
AgentEventPayload::ServerToolUse {
id,
name: tn,
input,
} => {
conv.mark_active();
crate::server_tool_display::handle_server_tool_use(conv, id, tn, &input);
obs.status = AgentStatus::Running;
}
AgentEventPayload::ServerToolResult {
tool_use_id,
content,
} => {
conv.mark_active();
crate::server_tool_display::handle_server_tool_result(conv, &tool_use_id, &content);
obs.status = AgentStatus::Running;
}
Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-session/tests/suite.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// Single test binary — includes all test modules
#[path = "suite/activity_grace_test.rs"]
mod activity_grace_test;
#[path = "suite/agent_handler_edge_test.rs"]
mod agent_handler_edge_test;
#[path = "suite/agent_handler_test.rs"]
Expand Down
113 changes: 113 additions & 0 deletions crates/loopal-session/tests/suite/activity_grace_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//! Tests for the "recently active" grace window used by the TUI status
//! bar to bridge the gap between `AwaitingInput` and `Running` events.

use std::time::Duration;

use loopal_protocol::{AgentEvent, AgentEventPayload};
use loopal_session::event_handler::apply_event;
use loopal_session::state::{ROOT_AGENT, SessionState};

fn make_state() -> SessionState {
SessionState::new("test-model".into(), "act".into())
}

#[test]
fn is_recently_active_true_after_activity_event() {
let mut state = make_state();
apply_event(
&mut state,
AgentEvent::root(AgentEventPayload::Stream { text: "hi".into() }),
);
assert!(
state.agents[ROOT_AGENT]
.conversation
.is_recently_active(Duration::from_secs(1)),
"Stream event must stamp last_active_at",
);
}

#[test]
fn is_recently_active_false_on_fresh_state() {
let state = make_state();
assert!(
!state.agents[ROOT_AGENT]
.conversation
.is_recently_active(Duration::from_millis(500)),
"a fresh conversation has no activity stamp",
);
}

#[test]
fn awaiting_input_keeps_recent_activity_from_prior_stream() {
// This is the exact scenario the fix targets: a Stream event stamps
// activity, then AwaitingInput (end of turn) arrives and clears the
// turn timer — but the grace window remains open so the TUI can keep
// its spinner alive until the next Running lands.
let mut state = make_state();
apply_event(
&mut state,
AgentEvent::root(AgentEventPayload::Stream {
text: "working".into(),
}),
);
apply_event(
&mut state,
AgentEvent::root(AgentEventPayload::AwaitingInput),
);
let conv = &state.agents[ROOT_AGENT].conversation;
// Turn timer cleared (AwaitingInput calls end_turn).
assert!(
!conv.is_recently_active(Duration::from_nanos(0)),
"zero-grace window is always expired",
);
assert!(
conv.is_recently_active(Duration::from_secs(1)),
"1s grace must still cover the Stream from moments ago",
);
}

#[test]
fn running_event_stamps_activity() {
let mut state = make_state();
apply_event(&mut state, AgentEvent::root(AgentEventPayload::Running));
assert!(
state.agents[ROOT_AGENT]
.conversation
.is_recently_active(Duration::from_secs(1)),
);
}

#[test]
fn tool_call_stamps_activity() {
let mut state = make_state();
apply_event(
&mut state,
AgentEvent::root(AgentEventPayload::ToolCall {
id: "t1".into(),
name: "Read".into(),
input: serde_json::json!({}),
}),
);
assert!(
state.agents[ROOT_AGENT]
.conversation
.is_recently_active(Duration::from_secs(1)),
);
}

#[test]
fn reset_timer_clears_activity() {
let mut state = make_state();
apply_event(&mut state, AgentEvent::root(AgentEventPayload::Running));
state
.agents
.get_mut(ROOT_AGENT)
.unwrap()
.conversation
.reset_timer();
assert!(
!state.agents[ROOT_AGENT]
.conversation
.is_recently_active(Duration::from_secs(1)),
);
}
86 changes: 48 additions & 38 deletions crates/loopal-tui/src/views/unified_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
/// `⠹ Streaming 12s ACT claude-sonnet ctx:45k/200k ↑3.2k ↓1.1k cache:87%`
///
/// Agent indicators moved to dedicated `agent_panel`.
use std::sync::OnceLock;
use std::time::{Duration, Instant};

use ratatui::prelude::*;
use ratatui::widgets::Paragraph;

Expand All @@ -12,17 +15,32 @@ use loopal_session::state::SessionState;
/// Braille spinner frames — 10 frames at ~100ms tick = smooth rotation.
pub const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];

/// Grace window after the last agent-activity event during which we keep the
/// status spinner/timer live. Covers the short gap between `AwaitingInput`
/// and the next `Running` event (events hop across agent-proc → hub → TUI)
/// plus any ordering jitter in the broadcast/mpsc bridge.
const ACTIVITY_GRACE: Duration = Duration::from_millis(750);

/// Render the unified status bar (1 line).
pub fn render_unified_status(f: &mut Frame, state: &SessionState, area: Rect) {
let is_plan = state.mode == "plan";
let mut spans: Vec<Span<'static>> = Vec::with_capacity(16);
let conv = state.active_conversation();
let elapsed = conv.turn_elapsed();
let base_elapsed = conv.turn_elapsed();
let is_active = is_agent_active(state);
// Spinner animation must keep moving even if the per-turn timer froze
// (e.g. during the brief window between turns or when viewing an agent
// whose turn just ended). A monotonic global clock decouples the spinner
// from any specific `turn_start`.
let spinner_elapsed = if is_active {
animation_clock()
} else {
base_elapsed
};

// Spinner / status icon + label + elapsed time (primary cluster)
spans.push(Span::raw(" "));
let (icon, icon_style, label) = status_icon_and_label(state, elapsed, is_active);
let (icon, icon_style, label) = status_icon_and_label(state, spinner_elapsed, is_active);
spans.push(Span::styled(icon, icon_style));
spans.push(Span::styled(format!(" {label}"), icon_style));
spans.push(Span::raw(" "));
Expand All @@ -31,7 +49,7 @@ pub fn render_unified_status(f: &mut Frame, state: &SessionState, area: Rect) {
} else {
dim_style()
};
spans.push(Span::styled(format_duration(elapsed), time_style));
spans.push(Span::styled(format_duration(base_elapsed), time_style));

// Mode
spans.push(Span::raw(" "));
Expand Down Expand Up @@ -77,49 +95,27 @@ pub fn render_unified_status(f: &mut Frame, state: &SessionState, area: Rect) {
fn status_icon_and_label(
state: &SessionState,
elapsed: std::time::Duration,
_is_active: bool,
is_active: bool,
) -> (String, Style, &'static str) {
let conv = state.active_conversation();
let spin = || spinner_frame(elapsed).to_string();
if conv.thinking_active {
let frame = spinner_frame(elapsed);
(
frame.to_string(),
Style::default().fg(Color::Magenta),
"Thinking",
)
(spin(), Style::default().fg(Color::Magenta), "Thinking")
} else if !conv.streaming_text.is_empty() {
let frame = spinner_frame(elapsed);
(
frame.to_string(),
Style::default().fg(Color::Green),
"Streaming",
)
(spin(), Style::default().fg(Color::Green), "Streaming")
} else if conv.pending_permission.is_some() {
(
"●".to_string(),
Style::default().fg(Color::Yellow),
"Waiting",
)
("●".into(), Style::default().fg(Color::Yellow), "Waiting")
} else if !state.is_active_agent_idle() {
let frame = spinner_frame(elapsed);
(
frame.to_string(),
Style::default().fg(Color::Cyan),
"Working",
)
(spin(), Style::default().fg(Color::Cyan), "Working")
} else if has_live_subagents(state) {
let frame = spinner_frame(elapsed);
(
frame.to_string(),
Style::default().fg(Color::Blue),
"Agents",
)
(spin(), Style::default().fg(Color::Blue), "Agents")
} else if is_active {
// Grace window: the agent's observable status just flipped to
// WaitingForInput but an activity event landed recently — keep
// the spinner alive until the authoritative Running event arrives.
(spin(), Style::default().fg(Color::Cyan), "Working")
} else {
(
"●".to_string(),
Style::default().fg(Color::DarkGray),
"Idle",
)
("●".into(), Style::default().fg(Color::DarkGray), "Idle")
}
}

Expand All @@ -135,6 +131,20 @@ fn is_agent_active(state: &SessionState) -> bool {
|| !conv.streaming_text.is_empty()
|| conv.thinking_active
|| has_live_subagents(state)
// Bridge the brief gap between an `AwaitingInput` event and the
// next `Running` event that re-arms the turn timer. Without this
// grace, the spinner flickers off between turns.
|| conv.is_recently_active(ACTIVITY_GRACE)
}

/// Process-wide monotonic clock for spinner animation. Decouples the
/// spinner's frame progression from any specific turn timer so that even
/// if `turn_elapsed` momentarily freezes (e.g. between `AwaitingInput` and
/// `Running`), the spinner keeps rotating smoothly while the agent is
/// otherwise active.
fn animation_clock() -> Duration {
static START: OnceLock<Instant> = OnceLock::new();
START.get_or_init(Instant::now).elapsed()
}

/// True if any sub-agent is still starting or running.
Expand Down
Loading