use super::{BASH_MODE_RESPONSE_TITLE, PREVIEW_LINE_MAX_DISPLAY_WIDTH, TOOL_PREVIEW_LINES};
use crate::{
hash::stable_hash,
output::{
ActivityId, ActivityKind, ActivityStatus, redact_sensitive_text, sanitize_display_controls,
},
rendering::{DisplayLine, RenderInputKind, render},
tools::ToolCapability,
tui::{
normalize_inline_separators,
state::MissionControlState,
transcript::{self, TranscriptEntry, TranscriptEntryKind},
},
};
#[cfg(test)]
use std::cell::Cell;
use std::sync::Arc;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
mod span_cache;
#[cfg(test)]
mod streaming_tests;
mod subagent_aggregation;
mod tool_adapters;
#[cfg(test)]
pub(crate) use span_cache::project_card_spans_reverse;
pub(crate) use span_cache::{
ProjectedTranscriptCard, project_card_spans, project_card_spans_reverse_from,
};
#[cfg(test)]
pub(crate) use span_cache::{cached_card_ptr_for_test, take_project_span_visits_for_test};
#[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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TranscriptCardRole {
UserPrompt,
Bash,
Reasoning,
Read,
Grep,
AstGrep,
Find,
ListFiles,
Tool,
Write,
HashEdit,
SubagentBatch,
Assistant,
ViewImage,
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) body_lines: Vec<DisplayLine>,
pub(crate) children: Vec<TranscriptCardChild>,
pub(crate) subagent_card: Option<SubagentCardSummary>,
}
#[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>,
pub(crate) agent: Option<String>,
pub(crate) model_line: Option<String>,
pub(crate) usage: Option<crate::output::NormalizedUsageAggregate>,
pub(crate) last_known_cache_percent: Option<u64>,
pub(crate) last_known_latest_cache_percent: Option<u64>,
pub(crate) live_tail: Vec<String>,
pub(crate) activity_id: Option<ActivityId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SubagentCardSummary {
pub(crate) total: usize,
pub(crate) done: usize,
pub(crate) failed: usize,
pub(crate) running: usize,
pub(crate) selected_task_id: Option<String>,
pub(crate) batch_id: Option<ActivityId>,
}
pub(crate) fn project_activity_card(
state: &MissionControlState,
node: &crate::tui::activity::ActivityNode,
) -> TranscriptCard {
let id = TranscriptCardId {
entry_index: usize::MAX,
};
let mut card = match &node.kind {
ActivityKind::Tool | ActivityKind::SubagentBatch => {
project_activity_tool_card(state, id, node)
}
ActivityKind::Assistant => {
let source = activity_detail_or_preview(node);
if node.metadata.label.starts_with("reasoning") {
basic_card(id, TranscriptCardRole::Reasoning, "Reasoning", source)
} else {
assistant_card(
id,
source,
matches!(
node.status,
ActivityStatus::Queued | ActivityStatus::Running | ActivityStatus::Writing
),
false,
None,
)
}
}
ActivityKind::SubagentTask => basic_card(
id,
TranscriptCardRole::Tool,
"Subagent Task",
activity_preview_or_label(node),
),
ActivityKind::Hook => basic_card(
id,
TranscriptCardRole::Diagnostic,
"Hook",
activity_preview_or_label(node),
),
ActivityKind::ProviderContextInjection => basic_card(
id,
TranscriptCardRole::Diagnostic,
"Provider Context",
activity_preview_or_label(node),
),
ActivityKind::Diagnostic => basic_card(
id,
TranscriptCardRole::Diagnostic,
"Diagnostic",
activity_preview_or_label(node),
),
ActivityKind::Compaction => basic_card(
id,
TranscriptCardRole::Compaction,
"Compaction",
activity_preview_or_label(node),
),
};
if !matches!(&node.kind, ActivityKind::Tool | ActivityKind::SubagentBatch) {
card.metadata = activity_card_metadata(node);
}
card.status = transcript_status_from_activity(node.status);
normalize_projected_card(card)
}
fn project_activity_tool_card(
state: &MissionControlState,
id: TranscriptCardId,
node: &crate::tui::activity::ActivityNode,
) -> TranscriptCard {
let tool_name = if node.kind == ActivityKind::SubagentBatch {
ToolCapability::Subagents.canonical_name().to_string()
} else {
node.tool_detail
.as_ref()
.and_then(|detail| nonempty_trimmed(detail.tool_name.as_ref()))
.map(str::to_string)
.or_else(|| node.transcript_tool_name.clone())
.unwrap_or_else(|| "Tool".to_string())
};
tool_adapters::project_tool_card(
state,
id,
Some(TranscriptActivityLink {
activity_id: node.id.clone(),
tool_name,
}),
&node.preview,
)
}
fn activity_detail_or_preview(node: &crate::tui::activity::ActivityNode) -> &str {
node.metadata
.detail
.as_deref()
.filter(|detail| !detail.trim().is_empty())
.unwrap_or(node.preview.as_str())
}
fn activity_preview_or_label(node: &crate::tui::activity::ActivityNode) -> &str {
if node.preview.trim().is_empty() {
node.metadata.label.as_str()
} else {
node.preview.as_str()
}
}
fn activity_card_metadata(node: &crate::tui::activity::ActivityNode) -> Vec<String> {
["identity", "agent", "provider", "model", "reasoning"]
.into_iter()
.filter_map(|wanted| {
node.metadata
.fields
.iter()
.find(|(key, value)| key == wanted && !value.trim().is_empty())
.map(|(_, value)| format!("{wanted} • {}", sanitized_single_line(value)))
})
.collect()
}
pub(crate) fn project_cards(state: &MissionControlState) -> Vec<TranscriptCard> {
if state.transcript.is_empty() {
return vec![TranscriptCard {
id: TranscriptCardId { entry_index: 0 },
role: TranscriptCardRole::Empty,
status: TranscriptCardStatus::None,
title: "Transcript".to_string(),
metadata: Vec::new(),
body: "Transcript will appear here. Type a prompt below.".to_string(),
body_lines: Vec::new(),
children: Vec::new(),
subagent_card: None,
}];
}
project_card_spans(state)
.map(|span| {
let mut card = span.card.as_ref().clone();
card.id.entry_index = span.first_entry_index;
card
})
.collect()
}
fn project_entry(
state: &MissionControlState,
entry_index: usize,
entry: &TranscriptEntry,
) -> 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 id = TranscriptCardId { entry_index };
let active_assistant = state.active_assistant_transcript_index() == Some(entry_index);
let body = entry.body();
let card = match entry.kind() {
TranscriptEntryKind::UserPrompt { automatic: true } => {
legacy_diagnostic_card(id, entry.rendered())
}
TranscriptEntryKind::UserPrompt { automatic: false } => {
basic_card(id, TranscriptCardRole::UserPrompt, "", body)
}
TranscriptEntryKind::Bash => basic_card(id, TranscriptCardRole::Bash, "Bash", body),
TranscriptEntryKind::Thinking => {
basic_card(id, TranscriptCardRole::Reasoning, "Reasoning", body)
}
TranscriptEntryKind::Assistant => assistant_card(
id,
body,
active_assistant,
!state.transcript_selection_active()
&& state.final_assistant_plain_first_marker(entry_index) == 1,
Some(state),
),
TranscriptEntryKind::Tool => tool_adapters::project_tool_card(state, id, link, body),
TranscriptEntryKind::Error => basic_card(id, TranscriptCardRole::Error, "Error", body),
TranscriptEntryKind::Session => {
basic_card(id, TranscriptCardRole::Session, "Session", body)
}
TranscriptEntryKind::Compaction => {
basic_card(id, TranscriptCardRole::Compaction, "Compaction", body)
}
TranscriptEntryKind::Canceled => TranscriptCard {
status: TranscriptCardStatus::Canceled,
subagent_card: None,
..basic_card(id, TranscriptCardRole::Diagnostic, "Canceled", body)
},
TranscriptEntryKind::HookDiagnostic => {
basic_card(id, TranscriptCardRole::Diagnostic, "Hook diagnostic", body)
}
TranscriptEntryKind::Diagnostic { level } => {
basic_card(id, TranscriptCardRole::Diagnostic, level, body)
}
TranscriptEntryKind::ProviderContextInjection
| TranscriptEntryKind::SubdirInstruction
| TranscriptEntryKind::LegacyDiagnostic => legacy_diagnostic_card(id, entry.rendered()),
};
normalize_projected_card(card)
}
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: if role == TranscriptCardRole::Error {
TranscriptCardStatus::Failed
} else {
TranscriptCardStatus::None
},
title: title.into(),
metadata: Vec::new(),
body,
body_lines: Vec::new(),
children: Vec::new(),
subagent_card: None,
}
}
fn normalize_projected_card(mut card: TranscriptCard) -> TranscriptCard {
debug_assert!(
!card
.title
.trim_start()
.starts_with(['✓', '✗', '●', '⟳', '○', '⊘'])
);
card.title = normalize_inline_separators(card.title.trim_start());
if card.status == TranscriptCardStatus::Canceled
&& matches!(
card.role,
TranscriptCardRole::Bash
| TranscriptCardRole::Read
| TranscriptCardRole::Tool
| TranscriptCardRole::Write
| TranscriptCardRole::ViewImage
| TranscriptCardRole::HashEdit
| TranscriptCardRole::Grep
| TranscriptCardRole::AstGrep
| TranscriptCardRole::Find
| TranscriptCardRole::ListFiles
)
&& !card.title.ends_with(" • canceled")
{
card.title.push_str(" • canceled");
}
card
}
fn assistant_card(
id: TranscriptCardId,
body: &str,
streaming: bool,
plain_first_final: bool,
state: Option<&MissionControlState>,
) -> TranscriptCard {
let body = transcript::sanitize_preview(body.trim_end_matches('\n'));
let body_lines = state
.and_then(|state| streaming_assistant_body_lines(state, id.entry_index, &body, streaming))
.unwrap_or_else(|| {
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 {
"Agent …".to_string()
} else {
"Agent".to_string()
},
metadata: Vec::new(),
body,
body_lines,
children: Vec::new(),
subagent_card: None,
}
}
fn streaming_assistant_body_lines(
state: &MissionControlState,
entry_index: usize,
body: &str,
streaming: bool,
) -> Option<Vec<DisplayLine>> {
let entry_index = state
.transcript_absolute_index(entry_index)
.unwrap_or(entry_index);
let mut cache = state.transcript_cache.borrow_mut();
if !streaming || body.len() > transcript::MAX_STREAMING_CACHE_BYTES {
if cache
.streaming
.as_ref()
.is_some_and(|cached| cached.entry_index == entry_index)
{
cache.streaming = None;
}
return None;
}
let cached = cache.streaming.get_or_insert_with(Default::default);
if cached.entry_index != entry_index || !body.starts_with(&cached.completed_prefix) {
*cached = transcript::CachedStreamingTranscript {
entry_index,
..Default::default()
};
}
let completed_end = body.rfind('\n').map_or(0, |index| index + 1);
let added = &body[cached.completed_prefix.len()..completed_end];
cached
.completed_lines
.extend(added.split_terminator('\n').map(DisplayLine::plain));
cached.completed_prefix.push_str(added);
let mut lines = cached.completed_lines.clone();
lines.extend(render(&body[completed_end..], RenderInputKind::Plain));
Some(lines)
}
fn is_bash_mode_response_card(
state: &MissionControlState,
entry_index: usize,
tool_capability: Option<ToolCapability>,
) -> bool {
tool_capability == Some(ToolCapability::Bash)
&& entry_index
.checked_sub(1)
.and_then(|index| state.transcript.get(index))
.is_some_and(|entry| matches!(entry.kind(), TranscriptEntryKind::Bash))
}
fn full_activity_preview(text: &str) -> String {
redact_sensitive_text(text.trim_end_matches('\n'))
}
fn sanitized_single_line(text: &str) -> String {
transcript::sanitize_preview(text)
.chars()
.flat_map(|character| match character {
'\n' | '\r' => "\\n".chars().collect::<Vec<_>>(),
'\t' => "\\t".chars().collect::<Vec<_>>(),
character => vec![character],
})
.collect()
}
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 tool_title(
linked_tool_name: Option<&str>,
transcript_tool_name: Option<&str>,
role: TranscriptCardRole,
) -> String {
linked_tool_name
.and_then(nonempty_trimmed)
.or_else(|| transcript_tool_name.and_then(nonempty_trimmed))
.map(str::to_string)
.unwrap_or_else(|| {
if role == TranscriptCardRole::SubagentBatch {
ToolCapability::Subagents.canonical_name().to_string()
} else {
"Tool".to_string()
}
})
}
fn nonempty_trimmed(text: &str) -> Option<&str> {
let trimmed = text.trim();
(!trimmed.is_empty()).then_some(trimmed)
}
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 transcript_tool_name(state: &MissionControlState, entry_index: usize) -> Option<&str> {
state
.transcript
.get(entry_index)?
.tool_data()
.map(|data| data.identity.name())
}
fn transcript_tool_status(state: &MissionControlState, entry_index: usize) -> TranscriptCardStatus {
state
.transcript
.get(entry_index)
.and_then(TranscriptEntry::tool_data)
.and_then(|data| data.identity.status())
.map(transcript_status_from_activity)
.unwrap_or(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
}