use super::{BASH_MODE_RESPONSE_TITLE, PREVIEW_LINE_MAX_DISPLAY_WIDTH, TOOL_PREVIEW_LINES};
use crate::{
hash::stable_hash,
output::{ActivityId, ActivityStatus, redact_sensitive_text},
rendering::{DisplayLine, RenderInputKind, render},
tui::{
INLINE_SEPARATOR_CHAR, normalize_inline_separators, rsplit_once_inline_separator,
state::MissionControlState,
transcript::{self},
},
};
#[cfg(test)]
use std::cell::Cell;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[cfg(test)]
thread_local! {
static PROJECT_ENTRY_COUNT: Cell<usize> = const { Cell::new(0) };
}
#[cfg(test)]
pub(crate) fn reset_project_entry_count_for_test() {
PROJECT_ENTRY_COUNT.with(|count| count.set(0));
}
#[cfg(test)]
pub(crate) fn project_entry_count_for_test() -> usize {
PROJECT_ENTRY_COUNT.with(Cell::get)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub(crate) struct TranscriptCardId {
pub(crate) entry_index: usize,
pub(crate) activity_id: Option<ActivityId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TranscriptCardRole {
UserPrompt,
Bash,
Reasoning,
Tool,
SubagentBatch,
Assistant,
Session,
Diagnostic,
Compaction,
Error,
Empty,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TranscriptCardStatus {
Running,
Success,
Failed,
Canceled,
None,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TranscriptActivityLink {
pub(crate) activity_id: ActivityId,
pub(crate) tool_name: String,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct TranscriptCard {
pub(crate) id: TranscriptCardId,
pub(crate) role: TranscriptCardRole,
pub(crate) status: TranscriptCardStatus,
pub(crate) title: String,
pub(crate) metadata: Vec<String>,
pub(crate) body: String,
pub(crate) assistant_lines: Vec<DisplayLine>,
pub(crate) children: Vec<TranscriptCardChild>,
pub(crate) squad: Option<SquadSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TranscriptCardChild {
pub(crate) label: String,
pub(crate) status: TranscriptCardStatus,
pub(crate) metadata: Vec<String>,
pub(crate) body: String,
pub(crate) task_id: String,
pub(crate) intent: String,
pub(crate) identity: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SquadSummary {
pub(crate) total: usize,
pub(crate) done: usize,
pub(crate) failed: usize,
pub(crate) running: usize,
}
pub(crate) fn project_cards(state: &MissionControlState) -> Vec<TranscriptCard> {
if state.transcript.is_empty() {
return vec![TranscriptCard {
id: TranscriptCardId {
entry_index: 0,
activity_id: None,
},
role: TranscriptCardRole::Empty,
status: TranscriptCardStatus::None,
title: "Transcript".to_string(),
metadata: Vec::new(),
body: "Transcript will appear here. Type a prompt below.".to_string(),
assistant_lines: Vec::new(),
children: Vec::new(),
squad: None,
}];
}
state
.transcript
.iter()
.enumerate()
.map(|(entry_index, entry)| cached_project_entry(state, entry_index, entry))
.collect()
}
pub(super) fn cached_project_entry(
state: &MissionControlState,
entry_index: usize,
entry: &str,
) -> TranscriptCard {
let content_marker = transcript_card_content_marker(state, entry_index, entry);
let cache_index = state
.transcript_absolute_index(entry_index)
.unwrap_or(entry_index);
if let Some(cached) = state.transcript_cache.borrow().cards.get(&cache_index)
&& cached.content_marker == content_marker
{
return cached.card.clone();
}
let card = project_entry(state, entry_index, entry);
state.transcript_cache.borrow_mut().cards.insert(
cache_index,
transcript::CachedTranscriptCard {
content_marker,
card: card.clone(),
},
);
card
}
pub(super) fn transcript_card_content_marker(
state: &MissionControlState,
entry_index: usize,
entry: &str,
) -> u64 {
let mut hash = stable_hash(entry.as_bytes());
if state.active_assistant_transcript_index() == Some(entry_index) {
hash ^= 0x9e37_79b9_7f4a_7c15;
}
let plain_first_marker = state.final_assistant_plain_first_marker(entry_index);
match plain_first_marker {
1 => {
hash ^= 0x517c_c1b7_dae5_8a2d;
if state.transcript_selection_active() {
hash ^= 0x3c8b_a73d_142f_e9b6;
}
}
2 => hash ^= 0xa24f_9c3e_65b1_d47a,
_ => {}
}
if let Some(absolute_index) = state.transcript_absolute_index(entry_index)
&& let Some(link) = state.transcript_activity_links.get(&absolute_index)
{
hash = hash_combine(hash, stable_hash(link.activity_id.as_str().as_bytes()));
hash = hash_combine(hash, stable_hash(link.tool_name.as_bytes()));
}
hash
}
pub(super) fn hash_combine(left: u64, right: u64) -> u64 {
left ^ right
.wrapping_add(0x9e37_79b9_7f4a_7c15)
.wrapping_add(left << 6)
.wrapping_add(left >> 2)
}
fn project_entry(state: &MissionControlState, entry_index: usize, entry: &str) -> TranscriptCard {
#[cfg(any(test, debug_assertions))]
state.perf.increment_projected_cards();
#[cfg(not(any(test, debug_assertions)))]
crate::tui::perf::increment_projected_cards();
#[cfg(test)]
PROJECT_ENTRY_COUNT.with(|count| count.set(count.get().saturating_add(1)));
let link = state
.transcript_absolute_index(entry_index)
.and_then(|index| state.transcript_activity_links.get(&index))
.cloned();
let activity_id = link.as_ref().map(|link| link.activity_id.clone());
let id = TranscriptCardId {
entry_index,
activity_id,
};
let active_assistant = state.active_assistant_transcript_index() == Some(entry_index);
if let Some((_, kind, body)) = transcript::transcript_entry_parts(entry) {
return match kind {
"you" => basic_card(id, TranscriptCardRole::UserPrompt, "👤You", body),
"bash" => basic_card(id, TranscriptCardRole::Bash, "⌘ Bash", body),
"thinking" => basic_card(
id,
TranscriptCardRole::Reasoning,
"🧠Reasoning Summary",
body,
),
"assistant" => assistant_card(
id,
body,
active_assistant,
!state.transcript_selection_active()
&& state.final_assistant_plain_first_marker(entry_index) == 1,
),
"tool" => tool_card(state, id, link, body),
"error" => basic_card(id, TranscriptCardRole::Error, "Error", body),
"session" => basic_card(id, TranscriptCardRole::Session, "Session", body),
"compact" => basic_card(id, TranscriptCardRole::Compaction, "🗜Compaction", body),
_ => legacy_diagnostic_card(id, entry),
};
}
if let Some(body) = entry.strip_prefix("hook: ") {
return basic_card(id, TranscriptCardRole::Diagnostic, "Hook diagnostic", body);
}
if let Some(body) = entry.strip_prefix("canceled: ") {
return TranscriptCard {
status: TranscriptCardStatus::Canceled,
squad: None,
..basic_card(id, TranscriptCardRole::Diagnostic, "Canceled", body)
};
}
if let Some((level, body)) = entry.split_once(": ") {
let normalized = level.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "info" | "warn" | "warning") {
return basic_card(id, TranscriptCardRole::Diagnostic, level.trim(), body);
}
if normalized == "error" {
return basic_card(id, TranscriptCardRole::Error, "Error", body);
}
}
legacy_diagnostic_card(id, entry)
}
fn basic_card(
id: TranscriptCardId,
role: TranscriptCardRole,
title: impl Into<String>,
body: &str,
) -> TranscriptCard {
let body = transcript::sanitize_preview(body.trim_end_matches('\n'));
let body = if matches!(
role,
TranscriptCardRole::Session | TranscriptCardRole::Error
) {
normalize_inline_separators(&body)
} else {
body
};
TranscriptCard {
id,
role,
status: TranscriptCardStatus::None,
title: title.into(),
metadata: Vec::new(),
body,
assistant_lines: Vec::new(),
children: Vec::new(),
squad: None,
}
}
fn assistant_card(
id: TranscriptCardId,
body: &str,
streaming: bool,
plain_first_final: bool,
) -> TranscriptCard {
let body = transcript::sanitize_preview(body.trim_end_matches('\n'));
let assistant_lines = render(
&body,
if streaming || plain_first_final {
RenderInputKind::Plain
} else {
RenderInputKind::Markdown
},
);
TranscriptCard {
id,
role: TranscriptCardRole::Assistant,
status: if streaming {
TranscriptCardStatus::Running
} else {
TranscriptCardStatus::Success
},
title: if streaming {
"🤖Assistant …".to_string()
} else {
"🤖Assistant".to_string()
},
metadata: Vec::new(),
body,
assistant_lines,
children: Vec::new(),
squad: None,
}
}
fn tool_card(
state: &MissionControlState,
id: TranscriptCardId,
link: Option<TranscriptActivityLink>,
body: &str,
) -> TranscriptCard {
let activity = link
.as_ref()
.and_then(|link| state.nodes.get(&link.activity_id).map(|node| (link, node)));
let role = if link
.as_ref()
.is_some_and(|link| link.tool_name == "subagents")
|| body.contains("subagents")
{
TranscriptCardRole::SubagentBatch
} else {
TranscriptCardRole::Tool
};
let bash_mode_response = is_bash_mode_response_card(state, id.entry_index, link.as_ref());
let title = if bash_mode_response {
BASH_MODE_RESPONSE_TITLE.to_string()
} else {
tool_title(
link.as_ref().map(|link| link.tool_name.as_str()),
activity.map(|(_, node)| node.metadata.label.as_str()),
role,
body,
)
};
let mut status = status_from_tool_body(body);
let metadata = Vec::new();
let mut preview = String::new();
let children = if role == TranscriptCardRole::SubagentBatch {
let normalized =
normalize_inline_separators(&transcript::sanitize_preview(body.trim_end_matches('\n')));
subagent_children(
state,
link.as_ref().map(|link| &link.activity_id),
&normalized,
)
} else {
Vec::new()
};
if let Some((_, node)) = activity {
status = transcript_status_from_activity(node.status);
preview = if bash_mode_response {
full_activity_preview(&node.preview)
} else if role != TranscriptCardRole::SubagentBatch {
bounded_lines(&node.preview, TOOL_PREVIEW_LINES)
} else {
String::new()
};
}
let body =
normalize_inline_separators(&transcript::sanitize_preview(body.trim_end_matches('\n')));
if preview.is_empty() && role != TranscriptCardRole::SubagentBatch {
preview = body.clone();
}
let squad = if role == TranscriptCardRole::SubagentBatch {
Some(squad_summary_from_children(
&children,
activity.map(|(_, node)| node),
))
} else {
None
};
TranscriptCard {
id,
role,
status,
title: transcript::sanitize_preview(&title),
metadata,
body: preview,
assistant_lines: Vec::new(),
children,
squad,
}
}
fn is_bash_mode_response_card(
state: &MissionControlState,
entry_index: usize,
link: Option<&TranscriptActivityLink>,
) -> bool {
link.is_some_and(|link| matches!(link.tool_name.as_str(), "bash" | "shell"))
&& entry_index
.checked_sub(1)
.and_then(|index| state.transcript.get(index))
.and_then(|entry| transcript::transcript_entry_parts(entry))
.is_some_and(|(_, kind, _)| kind == "bash")
}
fn full_activity_preview(text: &str) -> String {
redact_sensitive_text(text.trim_end_matches('\n'))
}
fn tool_title(
linked_tool_name: Option<&str>,
activity_label: Option<&str>,
role: TranscriptCardRole,
body: &str,
) -> String {
linked_tool_name
.and_then(nonempty_trimmed)
.or_else(|| tool_name_from_status_body(body))
.or_else(|| activity_label.and_then(tool_name_from_activity_label))
.map(str::to_string)
.unwrap_or_else(|| {
if role == TranscriptCardRole::SubagentBatch {
"subagents".to_string()
} else {
"Tool".to_string()
}
})
}
fn nonempty_trimmed(text: &str) -> Option<&str> {
let trimmed = text.trim();
(!trimmed.is_empty()).then_some(trimmed)
}
fn tool_name_from_status_body(body: &str) -> Option<&str> {
let mut text = body.trim_start();
if let Some(rest) = text
.strip_prefix('✓')
.or_else(|| text.strip_prefix('✗'))
.or_else(|| text.strip_prefix('⟳'))
{
text = rest.trim_start();
}
tool_name_from_activity_label(text)
}
fn tool_name_from_activity_label(label: &str) -> Option<&str> {
let first_segment = label.split([INLINE_SEPARATOR_CHAR, '·']).next()?.trim();
first_segment
.split_whitespace()
.next()
.and_then(nonempty_trimmed)
}
fn subagent_children(
state: &MissionControlState,
activity_id: Option<&ActivityId>,
body: &str,
) -> Vec<TranscriptCardChild> {
let from_activity = activity_id
.and_then(|id| state.children.get(id))
.map(|children| {
children
.iter()
.filter_map(|child_id| {
let node = state.nodes.get(child_id)?;
let label = normalize_inline_separators(&node.metadata.label);
let (task_id, intent) = parse_subagent_intent(&label);
let identity = identity_from_fields(&node.metadata.fields);
let status = transcript_status_from_activity(node.status);
let body = if status == TranscriptCardStatus::Failed {
first_non_empty_line(&node.preview)
.or_else(|| {
node.metadata
.detail
.as_deref()
.and_then(first_non_empty_line)
})
.unwrap_or_default()
} else {
String::new()
};
Some(TranscriptCardChild {
label,
status,
metadata: Vec::new(),
body,
task_id,
intent,
identity,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !from_activity.is_empty() {
return from_activity;
}
body.lines()
.filter_map(|line| {
let trimmed = line.trim();
let rest = trimmed
.strip_prefix("├─ ")
.or_else(|| trimmed.strip_prefix("└─ "))?;
let (label, status) = rsplit_once_inline_separator(rest)
.map(|(label, status)| (label, status_from_text(status)))
.unwrap_or((rest, TranscriptCardStatus::None));
let sanitized = transcript::sanitize_preview(label);
let (task_id, intent) = parse_subagent_intent(&sanitized);
Some(TranscriptCardChild {
label: sanitized,
status,
metadata: Vec::new(),
body: String::new(),
task_id,
intent,
identity: None,
})
})
.collect()
}
fn parse_subagent_intent(label: &str) -> (String, String) {
let (task_id, rest) = extract_task_id(label);
if task_id.is_empty() {
return (String::new(), label.trim().to_string());
}
let intent = rest
.split([INLINE_SEPARATOR_CHAR, '·'])
.map(str::trim)
.filter(|segment| !segment.is_empty() && !segment.starts_with("depth"))
.collect::<Vec<_>>()
.join(" • ");
(task_id, intent)
}
fn extract_task_id(label: &str) -> (String, &str) {
let trimmed = label.trim();
let Some(rest_after_g) = trimmed.strip_prefix('g') else {
return (String::new(), trimmed);
};
let digits_end = rest_after_g
.bytes()
.position(|byte| !byte.is_ascii_digit())
.unwrap_or(rest_after_g.len());
if digits_end == 0 {
return (String::new(), trimmed);
}
let task_id = format!("g{}", &rest_after_g[..digits_end]);
let remainder = rest_after_g[digits_end..].trim_start_matches(['•', '·', ' ', '\t']);
(task_id, remainder)
}
fn identity_from_fields(fields: &[(String, String)]) -> Option<String> {
fields
.iter()
.find_map(|(key, value)| (key == "identity").then(|| normalize_inline_separators(value)))
}
fn first_non_empty_line(preview: &str) -> Option<String> {
preview
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(|line| normalize_inline_separators(&redact_sensitive_text(line)))
}
fn squad_summary_from_children(
children: &[TranscriptCardChild],
node: Option<&crate::tui::activity::ActivityNode>,
) -> SquadSummary {
let (done, failed, running) = children.iter().fold(
(0usize, 0usize, 0usize),
|(done, failed, running), child| match child.status {
TranscriptCardStatus::Success => (done + 1, failed, running),
TranscriptCardStatus::Failed => (done, failed + 1, running),
TranscriptCardStatus::Running => (done, failed, running + 1),
TranscriptCardStatus::Canceled => (done + 1, failed, running),
TranscriptCardStatus::None => (done, failed, running),
},
);
let total = children
.len()
.max(node.and_then(squad_total_from_fields).unwrap_or(0));
SquadSummary {
total: total.max(done + failed + running),
done,
failed,
running,
}
}
fn squad_total_from_fields(node: &crate::tui::activity::ActivityNode) -> Option<usize> {
node.metadata.fields.iter().find_map(|(key, value)| {
(key == "tasks")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
}
fn legacy_diagnostic_card(id: TranscriptCardId, entry: &str) -> TranscriptCard {
basic_card(
id,
TranscriptCardRole::Diagnostic,
"Legacy diagnostic",
entry,
)
}
pub(super) fn transcript_status_from_activity(status: ActivityStatus) -> TranscriptCardStatus {
match status {
ActivityStatus::Queued | ActivityStatus::Running | ActivityStatus::Writing => {
TranscriptCardStatus::Running
}
ActivityStatus::Success => TranscriptCardStatus::Success,
ActivityStatus::Failed => TranscriptCardStatus::Failed,
ActivityStatus::Canceled => TranscriptCardStatus::Canceled,
}
}
fn status_from_tool_body(body: &str) -> TranscriptCardStatus {
let trimmed = body.trim_start();
if trimmed.starts_with('✓') {
TranscriptCardStatus::Success
} else if trimmed.starts_with('✗') {
TranscriptCardStatus::Failed
} else if trimmed.starts_with('⟳') {
TranscriptCardStatus::Running
} else {
TranscriptCardStatus::None
}
}
fn status_from_text(text: &str) -> TranscriptCardStatus {
match text.trim() {
"queued" | "running" | "writing" => TranscriptCardStatus::Running,
"completed" | "success" => TranscriptCardStatus::Success,
"failed" | "failure" => TranscriptCardStatus::Failed,
"canceled" => TranscriptCardStatus::Canceled,
_ => TranscriptCardStatus::None,
}
}
pub(super) fn bounded_lines(text: &str, max_lines: usize) -> String {
let text = redact_sensitive_text(text.trim_end_matches('\n'));
if text.is_empty() {
return String::new();
}
text.lines()
.take(max_lines)
.map(|line| truncate_preview_line(line, PREVIEW_LINE_MAX_DISPLAY_WIDTH))
.collect::<Vec<_>>()
.join("\n")
}
fn truncate_preview_line(line: &str, max_width: usize) -> String {
const MARKER: &str = "… [truncated]";
if UnicodeWidthStr::width(line) <= max_width {
return line.to_string();
}
let marker_width = UnicodeWidthStr::width(MARKER);
let content_width = max_width.saturating_sub(marker_width);
let mut out = String::new();
let mut width = 0usize;
for grapheme in line.graphemes(true) {
let next_width = width.saturating_add(UnicodeWidthStr::width(grapheme));
if next_width > content_width {
break;
}
out.push_str(grapheme);
width = next_width;
}
out.push_str(MARKER);
out
}