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<(usize, usize)>,
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) {
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) {
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<(usize, usize)>) -> Option<String> {
let (running, total) = counts?;
if total == 0 {
return None;
}
Some(format!("{running}▸/{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((2, 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▸/5");
assert_eq!(lines[3].fields[0].label, Some("session"));
assert_eq!(lines[3].fields[0].value, "sess_123");
}
}