rho-coding-agent 1.43.1

A lightweight agent harness inspired by Pi
Documentation
use std::time::{Duration, Instant};

use ratatui::{
    style::Style,
    text::{Line, Span},
};
use rho_sdk::{ProviderErrorKind, ProviderStreamResetReason};

use super::{
    render::{display_width, truncate_one_line},
    theme::Theme,
};

/// Rows occupied by the spinner/jump activity rail.
pub(super) const ACTIVITY_RAIL_ROWS: usize = 1;
/// Blank breathing room kept above the rail while bottom-following.
pub(super) const ACTIVITY_CONTENT_GAP_ROWS: usize = 1;

/// Tree connector for stacked activity-rail rows.
pub(super) fn tree_connector(is_last: bool) -> &'static str {
    if is_last {
        ""
    } else {
        ""
    }
}

/// Visible stacked rows shared by the subagent and process rails.
pub(super) const MAX_VISIBLE_RAIL_ROWS: usize = 2;
/// Content width clamp shared by stacked activity-rail rows.
const MAX_RAIL_CONTENT_WIDTH: usize = 52;

/// One stacked activity-rail row. Identity styling stays at the call site.
pub(super) struct RailRow {
    pub(super) connector: &'static str,
    pub(super) identity: Vec<Span<'static>>,
    pub(super) activity: String,
    pub(super) trailing: String,
    pub(super) row_style: Style,
}

impl RailRow {
    pub(super) fn into_line(self, width: usize) -> Line<'static> {
        const SEPARATOR: &str = "  ·  ";
        const MIN_GAP: usize = 2;

        let connector_width = display_width(self.connector);
        let content_width = width
            .saturating_sub(connector_width)
            .min(MAX_RAIL_CONTENT_WIDTH);
        let identity_plain: String = self
            .identity
            .iter()
            .map(|span| span.content.as_ref())
            .collect();
        let identity_width = display_width(&identity_plain);
        let separator_width = display_width(SEPARATOR);
        let trailing_width = display_width(&self.trailing);
        let fixed_width = identity_width + separator_width + MIN_GAP + trailing_width;
        let row_style = self.row_style;

        if fixed_width >= content_width {
            let detail = truncate_one_line(
                &format!(
                    "{identity_plain}{SEPARATOR}{}  {}",
                    self.activity, self.trailing
                ),
                content_width,
            );
            return Line::from(vec![
                Span::styled(self.connector, Theme::dim().patch(row_style)),
                Span::styled(detail, Theme::dim().patch(row_style)),
            ]);
        }

        let activity_width = content_width.saturating_sub(fixed_width);
        let activity = truncate_one_line(&self.activity, activity_width);
        let gap = " ".repeat(content_width.saturating_sub(
            identity_width + separator_width + display_width(&activity) + trailing_width,
        ));
        let mut spans = Vec::with_capacity(self.identity.len() + 5);
        spans.push(Span::styled(self.connector, Theme::dim().patch(row_style)));
        spans.extend(self.identity);
        spans.push(Span::styled(SEPARATOR, Theme::dim().patch(row_style)));
        spans.push(Span::styled(activity, Theme::text().patch(row_style)));
        spans.push(Span::styled(gap, row_style));
        spans.push(Span::styled(self.trailing, Theme::dim().patch(row_style)));
        Line::from(spans)
    }
}

/// Transcript rows reserved under the history panel while bottom-following with
/// activity chrome. Manual scroll keeps the full panel so content can sit under
/// the overlay.
pub(super) fn bottom_follow_activity_inset(activity_active: bool, bottom_follow: bool) -> usize {
    if activity_active && bottom_follow {
        ACTIVITY_RAIL_ROWS + ACTIVITY_CONTENT_GAP_ROWS
    } else {
        0
    }
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum ActivityPhase {
    #[default]
    Starting,
    WaitingForProvider,
    Thinking,
    Responding,
    PreparingTool,
    RunningTool,
    RetryingProvider,
    Compacting,
    WaitingForApproval,
    WaitingForInput,
}

impl ActivityPhase {
    fn label(self) -> &'static str {
        match self {
            Self::Starting => "starting",
            Self::WaitingForProvider => "waiting for provider",
            Self::Thinking => "thinking",
            Self::Responding => "responding",
            Self::PreparingTool => "preparing tool",
            Self::RunningTool => "running tool",
            Self::RetryingProvider => "retrying provider",
            Self::Compacting => "compacting context",
            Self::WaitingForApproval => "waiting for approval",
            Self::WaitingForInput => "waiting for input",
        }
    }
}

/// Structured provider-retry context for spinner/status copy.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct ProviderRetryHint {
    pub(super) reason: ProviderStreamResetReason,
}

impl ProviderRetryHint {
    pub(super) fn status_label(self) -> String {
        let rate_limited = matches!(
            self.reason.provider_error_kind(),
            Some(ProviderErrorKind::RateLimit)
        );
        match (
            rate_limited,
            self.reason.retry_after().filter(|delay| !delay.is_zero()),
        ) {
            (true, Some(delay)) => format!(
                "rate limited · retry in {}",
                rho_sdk::format_retry_after(delay)
            ),
            (true, None) => "rate limited · retrying".into(),
            (false, Some(delay)) => format!(
                "retrying provider · in {}",
                rho_sdk::format_retry_after(delay)
            ),
            (false, None) => "retrying provider response".into(),
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ActivityStatus {
    Parent {
        phase: ActivityPhase,
        retry: Option<ProviderRetryHint>,
    },
    Subagents(usize),
    ParentWithSubagents {
        phase: ActivityPhase,
        retry: Option<ProviderRetryHint>,
        subagent_count: usize,
    },
}

impl ActivityStatus {
    pub(super) fn from_parent_and_subagents(
        parent: Option<(ActivityPhase, Option<ProviderRetryHint>)>,
        subagent_count: usize,
    ) -> Option<Self> {
        match (parent, subagent_count) {
            (Some((phase, retry)), 0) => Some(Self::Parent { phase, retry }),
            (Some((phase, retry)), count) => Some(Self::ParentWithSubagents {
                phase,
                retry,
                subagent_count: count,
            }),
            (None, 0) => None,
            (None, count) => Some(Self::Subagents(count)),
        }
    }
}

pub(super) fn activity_width(available: usize, status: ActivityStatus) -> usize {
    let first_frame = LoadingSpinner::FRAMES[0];
    activity_labels(status)
        .into_iter()
        .find(|label| display_width(label) <= available)
        .map_or_else(
            || available.min(display_width(first_frame)),
            |label| display_width(&label),
        )
}

fn phase_label(phase: ActivityPhase, retry: Option<ProviderRetryHint>) -> String {
    if matches!(phase, ActivityPhase::RetryingProvider) {
        if let Some(retry) = retry {
            return retry.status_label();
        }
    }
    phase.label().to_string()
}

fn activity_labels(status: ActivityStatus) -> Vec<String> {
    let spinner = LoadingSpinner::FRAMES[0];
    let subagent_count = match status {
        ActivityStatus::Parent { .. } => 0,
        ActivityStatus::Subagents(count)
        | ActivityStatus::ParentWithSubagents {
            subagent_count: count,
            ..
        } => count,
    };
    let agents = if subagent_count == 1 {
        "1 agent".into()
    } else {
        format!("{subagent_count} agents")
    };
    match status {
        ActivityStatus::Parent { phase, retry } => {
            let label = phase_label(phase, retry);
            vec![format!("{spinner} {label}"), spinner.into()]
        }
        ActivityStatus::ParentWithSubagents { phase, retry, .. } => {
            let label = phase_label(phase, retry);
            vec![
                format!("{spinner} {label}  ·  {agents}"),
                format!("{spinner} {label} · {subagent_count}"),
                format!("{spinner} {subagent_count}"),
                spinner.into(),
            ]
        }
        ActivityStatus::Subagents(_) => vec![
            format!("{spinner} {agents} working"),
            format!("{spinner} {subagent_count} agents"),
            format!("{spinner} {subagent_count}"),
            spinner.into(),
        ],
    }
}

#[derive(Clone, Debug, Default)]
pub(super) struct LoadingSpinner {
    started_at: Option<Instant>,
}

impl LoadingSpinner {
    const FRAMES: [&'static str; 8] = ["", "", "", "", "", "", "", ""];
    pub(super) const FRAME_INTERVAL: Duration = Duration::from_millis(95);

    pub(super) fn start(&mut self) {
        self.started_at = Some(Instant::now());
    }

    pub(super) fn start_if_needed(&mut self) {
        if self.started_at.is_none() {
            self.start();
        }
    }

    pub(super) fn stop(&mut self) {
        self.started_at = None;
    }

    pub(super) fn frame_since(started_at: Instant, now: Instant) -> &'static str {
        let interval_ms = Self::FRAME_INTERVAL.as_millis().max(1);
        let frame = now
            .saturating_duration_since(started_at)
            .as_millis()
            .checked_div(interval_ms)
            .unwrap_or(0) as usize;
        Self::FRAMES[frame % Self::FRAMES.len()]
    }

    fn frame_at(&self, now: Instant) -> &'static str {
        let Some(started_at) = self.started_at else {
            return Self::FRAMES[0];
        };
        Self::frame_since(started_at, now)
    }

    pub(super) fn line(
        &self,
        now: Instant,
        available: usize,
        status: ActivityStatus,
    ) -> Line<'static> {
        let label = activity_labels(status)
            .into_iter()
            .find(|label| display_width(label) <= available)
            .unwrap_or_else(|| Self::FRAMES[0].chars().take(available).collect());
        let Some(rest) = label.strip_prefix(Self::FRAMES[0]) else {
            return Line::default();
        };
        Line::from(vec![
            Span::styled(self.frame_at(now), Theme::accent()),
            Span::styled(rest.to_string(), Theme::dim()),
        ])
    }
}

pub(super) fn jump_to_bottom_text(width: usize, binding: &str, alongside_activity: bool) -> String {
    let full = format!("↓ jump to bottom  {binding}");
    let compact = format!("↓ bottom {binding}");
    let shortcut = format!("{binding}");
    // Leave enough room for the compact activity label when both controls share a row.
    let activity_width = usize::from(alongside_activity)
        * (display_width(LoadingSpinner::FRAMES[0]) + display_width(" 0") + 1);
    let available = width.saturating_sub(activity_width);

    if display_width(&full) <= available {
        full
    } else if display_width(&compact) <= available {
        compact
    } else if display_width(&shortcut) <= width {
        shortcut
    } else {
        truncate_one_line(&shortcut, width)
    }
}

#[cfg(test)]
#[path = "activity_tests.rs"]
mod tests;