yolop 0.7.0

Yolop — a terminal coding agent built on everruns-runtime
//! Terminal-independent presentation model for user-visible yolop output.
//!
//! Runtime events are translated into transcript view-model entries in
//! `crate::transcript`; this module turns those entries plus live app state into
//! the semantic output hosts display. It intentionally has no ratatui/crossterm
//! dependency so transcript wording and status values can be tested without a
//! terminal buffer.

use crate::session_tasks_view::BackgroundCounts;
use crate::transcript::{Author, ChatLine, StreamKind, StreamPreview};

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PresentedTranscriptLine {
    pub author: Author,
    pub label: Option<&'static str>,
    pub text: String,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PresentationState {
    pub stream_preview: Option<StreamPreview>,
    pub busy: bool,
    pub turn_activity: Option<String>,
    pub model_id: String,
    pub provider_name: String,
    pub reasoning_effort: Option<String>,
    pub session_id: String,
    pub lines_count: usize,
    pub session_tokens: Option<u64>,
    pub status_layout: StatusLayout,
    pub hooks_summary: String,
    pub approval_mode: String,
    pub background: Option<BackgroundCounts>,
    pub goal_indicator: Option<String>,
    pub ask_indicator: Option<String>,
    pub worktree_compact: Option<String>,
    pub worktree_expanded: Option<(String, String)>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum StatusLayout {
    Compact,
    Expanded,
}

impl StatusLayout {
    pub(crate) fn base_row_count(self) -> u16 {
        match self {
            Self::Compact => 1,
            Self::Expanded => 4,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct StatusLine {
    pub fields: Vec<StatusField>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct StatusField {
    pub label: Option<&'static str>,
    pub value: String,
}

impl Author {
    pub(crate) fn label(&self) -> &'static str {
        match self {
            Author::User => "you",
            Author::Assistant => "agent",
            Author::Narration => "note",
            Author::Tool => "tool",
            Author::ToolDetail => "",
            Author::Diff => "diff",
            Author::System => "system",
        }
    }
}

impl StreamKind {
    pub(crate) fn label(self) -> &'static str {
        match self {
            StreamKind::Assistant => "agent",
            StreamKind::Tool => "tool",
        }
    }
}

impl PresentationState {
    pub(crate) fn status_row_count(&self) -> u16 {
        self.status_layout.base_row_count() + self.worktree_extra_status_rows()
    }

    fn worktree_extra_status_rows(&self) -> u16 {
        u16::from(self.status_layout == StatusLayout::Expanded && self.worktree_expanded.is_some())
    }

    pub(crate) fn status_lines(&self) -> Vec<StatusLine> {
        match self.status_layout {
            StatusLayout::Compact => compact_status_lines(self),
            StatusLayout::Expanded => expanded_status_lines(self),
        }
    }

    pub(crate) fn activity_text(&self) -> Option<&str> {
        self.busy
            .then(|| self.turn_activity.as_deref().unwrap_or("thinking"))
    }
}

pub(crate) fn present_transcript_line(chat: &ChatLine) -> PresentedTranscriptLine {
    let label = match chat.author {
        Author::ToolDetail => None,
        _ => Some(chat.author.label()),
    };
    PresentedTranscriptLine {
        author: chat.author.clone(),
        label,
        text: chat.text.clone(),
    }
}

#[cfg(test)]
pub(crate) fn plain_transcript_line(chat: &ChatLine) -> String {
    let line = present_transcript_line(chat);
    match line.label {
        Some(label) => format!("{label}{}", line.text),
        None => format!("           {}", line.text),
    }
}

fn compact_status_lines(state: &PresentationState) -> Vec<StatusLine> {
    vec![StatusLine {
        fields: status_contributions(state)
            .into_iter()
            .flatten()
            .collect::<Vec<_>>(),
    }]
}

fn expanded_status_lines(state: &PresentationState) -> Vec<StatusLine> {
    let mut counts = vec![
        status_value(message_count_label(state.lines_count)),
        status_field("tokens", token_label(state.session_tokens)),
    ];
    if let Some(bg) = background_label(state.background, false) {
        counts.push(status_field("bg", bg));
    }

    let mut lines = vec![
        StatusLine {
            fields: vec![
                status_value("[collapse ↑]"),
                status_field("provider", state.provider_name.clone()),
                status_field("model", state.model_id.clone()),
            ],
        },
        StatusLine {
            fields: vec![
                status_field("effort", effort_label(state)),
                status_field("approval", state.approval_mode.clone()),
                status_field("hooks", state.hooks_summary.clone()),
                status_field("goal", goal_label(state)),
                status_field("ask", ask_label(state)),
            ],
        },
        StatusLine { fields: counts },
        StatusLine {
            fields: vec![status_field("session", state.session_id.clone())],
        },
    ];
    if let Some((branch, path)) = &state.worktree_expanded {
        lines.push(StatusLine {
            fields: vec![
                status_field("worktree", branch.clone()),
                status_field("path", path.clone()),
            ],
        });
    }
    lines
}

fn status_contributions(state: &PresentationState) -> Vec<Vec<StatusField>> {
    let toggle_label = match state.status_layout {
        StatusLayout::Compact => "[expand ↓]",
        StatusLayout::Expanded => "[collapse ↑]",
    };
    let mut counts = vec![status_value(message_count_label(state.lines_count))];
    if let Some(bg) = background_label(state.background, true) {
        counts.push(status_field("bg", bg));
    }
    if let Some(wt) = &state.worktree_compact {
        counts.push(status_field("wt", wt.clone()));
    }
    vec![
        vec![
            status_value(toggle_label),
            status_value(state.provider_name.clone()),
            status_value(state.model_id.clone()),
        ],
        vec![
            status_field("effort", effort_label(state)),
            status_field("approval", state.approval_mode.clone()),
        ],
        vec![
            status_field("goal", goal_label(state)),
            status_field("ask", ask_label(state)),
        ],
        counts,
    ]
}

fn goal_label(state: &PresentationState) -> String {
    state.goal_indicator.clone().unwrap_or_else(|| "".into())
}

fn ask_label(state: &PresentationState) -> String {
    state.ask_indicator.clone().unwrap_or_else(|| "".into())
}

fn background_label(counts: Option<BackgroundCounts>, compact: bool) -> Option<String> {
    let counts = counts?;
    if counts.total == 0 {
        return None;
    }
    if compact {
        Some(format!(
            "{} run/{} sched/{}",
            counts.running, counts.scheduled, counts.total
        ))
    } else {
        Some(format!(
            "{} running · {} scheduled · {} total",
            counts.running, counts.scheduled, counts.total
        ))
    }
}

fn status_value(value: impl Into<String>) -> StatusField {
    StatusField {
        label: None,
        value: value.into(),
    }
}

fn status_field(label: &'static str, value: impl Into<String>) -> StatusField {
    StatusField {
        label: Some(label),
        value: value.into(),
    }
}

fn effort_label(state: &PresentationState) -> String {
    state
        .reasoning_effort
        .clone()
        .unwrap_or_else(|| "n/a".to_string())
}

fn token_label(tokens: Option<u64>) -> String {
    tokens
        .map(|tokens| tokens.to_string())
        .unwrap_or_else(|| "n/a".to_string())
}

fn message_count_label(count: usize) -> String {
    format!("{count} msgs")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::transcript::lines_for_event;
    use everruns_core::events::{Event as RuntimeEvent, EventContext, ToolCompletedData};
    use everruns_core::message::ContentPart;
    use everruns_core::typed_id::SessionId;
    use serde_json::json;

    fn state() -> PresentationState {
        PresentationState {
            stream_preview: None,
            busy: false,
            turn_activity: None,
            model_id: "gpt-5.5".to_string(),
            provider_name: "openai".to_string(),
            reasoning_effort: Some("medium".to_string()),
            session_id: "sess_123".to_string(),
            lines_count: 3,
            session_tokens: Some(42),
            status_layout: StatusLayout::Compact,
            hooks_summary: "none".to_string(),
            approval_mode: "normal".to_string(),
            background: None,
            goal_indicator: None,
            ask_indicator: None,
            worktree_compact: None,
            worktree_expanded: None,
        }
    }

    #[test]
    fn plain_transcript_line_exposes_user_visible_tool_output() {
        let line = ChatLine {
            author: Author::Tool,
            text: "✓ Bash `git status --short` exit=0".to_string(),
        };

        assert_eq!(
            plain_transcript_line(&line),
            "tool › ✓ Bash `git status --short` exit=0"
        );
    }

    #[test]
    fn plain_transcript_line_keeps_fallback_wording_visible_to_tests() {
        let mut data = ToolCompletedData::success(
            "call_bash".to_string(),
            "bash".to_string(),
            vec![ContentPart::text(
                json!({
                    "command": "git status --short",
                    "exit_code": 0
                })
                .to_string(),
            )],
            None,
        );
        data.narration = Some("Ran Bash".to_string());
        let event = RuntimeEvent::new(SessionId::new(), EventContext::empty(), data);
        let lines = lines_for_event(&event);

        let rendered = plain_transcript_line(&lines[0]);

        assert_eq!(rendered, "tool › ✓ Ran Bash  `git status --short` exit=0");
    }

    #[test]
    fn status_model_exposes_compact_status_values_without_terminal() {
        let lines = state().status_lines();

        assert_eq!(lines.len(), 1);
        let values = lines[0]
            .fields
            .iter()
            .map(|field| (field.label, field.value.as_str()))
            .collect::<Vec<_>>();
        assert!(values.contains(&(None, "[expand ↓]")));
        assert!(values.contains(&(None, "openai")));
        assert!(values.contains(&(None, "gpt-5.5")));
        assert!(values.contains(&(Some("effort"), "medium")));
        assert!(values.contains(&(Some("approval"), "normal")));
        assert!(values.contains(&(Some("goal"), "")));
        assert!(values.contains(&(None, "3 msgs")));
    }

    #[test]
    fn status_model_exposes_expanded_background_and_session_values() {
        let model = PresentationState {
            status_layout: StatusLayout::Expanded,
            background: Some(BackgroundCounts {
                running: 2,
                scheduled: 1,
                total: 5,
            }),
            ..state()
        };

        let lines = model.status_lines();

        assert_eq!(lines.len(), 4);
        assert_eq!(lines[0].fields[1].label, Some("provider"));
        assert_eq!(lines[0].fields[1].value, "openai");
        assert_eq!(lines[2].fields[2].label, Some("bg"));
        assert_eq!(
            lines[2].fields[2].value,
            "2 running · 1 scheduled · 5 total"
        );
        assert_eq!(lines[3].fields[0].label, Some("session"));
        assert_eq!(lines[3].fields[0].value, "sess_123");
    }
}