rho-coding-agent 1.10.0

A lightweight agent harness inspired by Pi
Documentation
use ratatui::{
    style::Style,
    text::{Line, Span},
};

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

const MAX_VISIBLE_ITEMS: usize = 3;

impl App {
    pub(in crate::tui) fn pending_input_height(&self) -> usize {
        let count = self.pending_input_count();
        if count == 0 {
            return 0;
        }
        1 + count.min(MAX_VISIBLE_ITEMS) + usize::from(self.pending_input_panel.focused)
    }

    pub(in crate::tui) fn pending_input_lines(&mut self, width: usize) -> Vec<Line<'static>> {
        let items = self.pending_input_refs();
        if items.is_empty() {
            self.pending_input_panel.focused = false;
            return Vec::new();
        }
        self.clamp_pending_input_selection(items.len());
        let selected = self.pending_input_panel.selected;
        let start = visible_start(selected, items.len(), MAX_VISIBLE_ITEMS);
        let steering_count = self.accepted_steering.len() + self.steering_prompts.len();
        let hint = if self.pending_input_panel.focused {
            "↑↓ select · enter edit · backspace discard · esc close".to_string()
        } else {
            format!(
                "{} edit · {} manage",
                self.info.runtime.keybindings.edit_pending_input,
                self.info.runtime.keybindings.manage_pending_input
            )
        };
        let mut lines = vec![pending_header_line(
            width,
            steering_count,
            self.queued_prompts.len(),
            &hint,
        )];
        lines.extend(
            items
                .iter()
                .skip(start)
                .take(MAX_VISIBLE_ITEMS)
                .enumerate()
                .map(|(visible_index, item)| {
                    self.pending_item_line(width, *item, start + visible_index == selected)
                }),
        );
        if self.pending_input_panel.focused {
            lines.push(Line::styled(
                truncate_one_line(
                    "  steer affects this run · next starts after this turn",
                    width,
                ),
                Theme::dim(),
            ));
        }
        lines
    }

    fn pending_item_line(
        &self,
        width: usize,
        item: PendingInputRef,
        selected: bool,
    ) -> Line<'static> {
        let marker = if selected { "" } else { "  " };
        let (label, context, prompt, label_style) = match item {
            PendingInputRef::AcceptedSteering(index) => {
                let entry = &self.accepted_steering[index];
                let context = if self.retracting_steering.as_ref() == Some(&entry.id) {
                    "retracting"
                } else {
                    "current run"
                };
                (
                    "STEER",
                    context,
                    &entry.prompt.display_prompt,
                    Theme::warning(),
                )
            }
            PendingInputRef::LocalSteering(index) => (
                "STEER",
                "sending",
                &self.steering_prompts[index].display_prompt,
                Theme::warning(),
            ),
            PendingInputRef::FollowUp(index) => (
                "NEXT",
                "after turn",
                &self.queued_prompts[index].display_prompt,
                Theme::accent(),
            ),
        };
        pending_item_line(width, marker, label, context, prompt, label_style, selected)
    }
}

fn pending_header_line(
    width: usize,
    steering_count: usize,
    follow_up_count: usize,
    hint: &str,
) -> Line<'static> {
    let steering = count_label(steering_count, "steer", "steers");
    let follow_up = count_label(follow_up_count, "follow-up", "follow-ups");
    let counts = match (steering_count, follow_up_count) {
        (0, _) => follow_up,
        (_, 0) => steering,
        _ => format!("{steering} · {follow_up}"),
    };
    let left = format!("  pending input · {counts}");
    let left_width = display_width(&left);
    let hint_width = display_width(hint);
    if left_width + hint_width + 2 <= width {
        return Line::from(vec![
            Span::styled(left, Theme::text_strong()),
            Span::raw(" ".repeat(width - left_width - hint_width)),
            Span::styled(hint.to_string(), Theme::dim()),
        ]);
    }
    Line::styled(
        truncate_one_line(&format!("{left}  {hint}"), width),
        Theme::text_strong(),
    )
}

fn pending_item_line(
    width: usize,
    marker: &str,
    label: &str,
    context: &str,
    prompt: &str,
    label_style: Style,
    selected: bool,
) -> Line<'static> {
    let compact = width < 34;
    let context = if compact { "" } else { context };
    let prefix = if context.is_empty() {
        format!("{marker}{label:<6} ")
    } else {
        format!("{marker}{label:<6} · {context:<11} ")
    };
    let prefix_width = display_width(&prefix);
    if prefix_width >= width {
        return Line::styled(
            truncate_one_line(&format!("{marker}{label} {prompt}"), width),
            label_style,
        );
    }
    let prompt = truncate_one_line(prompt, width - prefix_width);
    let marker_style = if selected {
        Theme::accent()
    } else {
        Theme::dim()
    };
    let context_prefix = if context.is_empty() {
        format!("{label:<6} ")
    } else {
        format!("{label:<6} · {context:<11} ")
    };
    Line::from(vec![
        Span::styled(marker.to_string(), marker_style),
        Span::styled(label.to_string(), label_style),
        Span::styled(context_prefix[label.len()..].to_string(), Theme::dim()),
        Span::styled(
            prompt,
            if selected {
                Theme::text_strong()
            } else {
                Theme::text()
            },
        ),
    ])
}

fn count_label(count: usize, singular: &str, plural: &str) -> String {
    let label = if count == 1 { singular } else { plural };
    format!("{count} {label}")
}

fn visible_start(selected: usize, count: usize, visible: usize) -> usize {
    selected
        .saturating_sub(visible.saturating_sub(1))
        .min(count.saturating_sub(visible))
}