use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TranscriptEntryKind {
UserPrompt { automatic: bool },
Bash,
Assistant,
Thinking,
Tool,
Error,
Session,
Compaction,
Canceled,
Diagnostic { level: String },
HookDiagnostic,
ProviderContextInjection,
SubdirInstruction,
LegacyDiagnostic,
}
impl TranscriptEntryKind {
pub(crate) fn is_assistant(&self) -> bool {
matches!(self, Self::Assistant)
}
pub(crate) fn is_thinking(&self) -> bool {
matches!(self, Self::Thinking)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TranscriptToolIdentity {
Summary(crate::output::ToolDisplaySummary),
Named {
name: String,
status: Option<crate::output::ActivityStatus>,
},
}
impl TranscriptToolIdentity {
pub(crate) fn name(&self) -> &str {
match self {
Self::Summary(summary) => &summary.tool_name,
Self::Named { name, .. } => name,
}
}
pub(crate) fn status(&self) -> Option<crate::output::ActivityStatus> {
use crate::output::{ActivityStatus, ToolStatus};
match self {
Self::Named { status, .. } => *status,
Self::Summary(summary) => Some(match summary.status {
ToolStatus::Running => ActivityStatus::Running,
ToolStatus::Writing => ActivityStatus::Writing,
ToolStatus::Success => ActivityStatus::Success,
ToolStatus::Failure => ActivityStatus::Failed,
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TranscriptSubagentSummary {
pub(crate) label: String,
pub(crate) task_id: String,
pub(crate) intent: String,
pub(crate) status: Option<crate::output::ActivityStatus>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TranscriptToolData {
pub(crate) identity: TranscriptToolIdentity,
pub(crate) children: Vec<TranscriptSubagentSummary>,
}
fn legacy_tool_data(body: &str) -> TranscriptToolData {
use crate::output::ActivityStatus;
let text = body.trim_start();
let status = match text.chars().next() {
Some('✓') => Some(ActivityStatus::Success),
Some('✗') => Some(ActivityStatus::Failed),
Some('●' | '⟳' | '○') => Some(ActivityStatus::Running),
Some('⊘') => Some(ActivityStatus::Canceled),
_ => None,
};
let name = text
.trim_start_matches(['✓', '✗', '●', '⟳', '○', '⊘'])
.trim_start()
.split(['•', '·'])
.next()
.unwrap_or_default()
.split_whitespace()
.next()
.unwrap_or_default()
.to_string();
let children = body
.lines()
.filter_map(|line| {
let rest = line
.trim()
.strip_prefix("├─ ")
.or_else(|| line.trim().strip_prefix("└─ "))?;
let (label, status) = crate::tui::rsplit_once_inline_separator(rest)
.map(|(label, status)| {
(
label,
match status.trim() {
"queued" => Some(ActivityStatus::Queued),
"running" | "writing" => Some(ActivityStatus::Running),
"completed" | "success" => Some(ActivityStatus::Success),
"failed" | "failure" => Some(ActivityStatus::Failed),
"canceled" | "cancelled" => Some(ActivityStatus::Canceled),
_ => None,
},
)
})
.unwrap_or((rest, None));
Some(subagent_summary(label, status))
})
.collect();
TranscriptToolData {
identity: TranscriptToolIdentity::Named { name, status },
children,
}
}
pub(crate) fn subagent_summary(
label: &str,
status: Option<crate::output::ActivityStatus>,
) -> TranscriptSubagentSummary {
let label = crate::tui::normalize_inline_separators(&sanitize_preview(label));
let trimmed = label.trim();
let (task_id, rest) = if let Some(after_g) = trimmed.strip_prefix('g') {
let digits = after_g.bytes().take_while(u8::is_ascii_digit).count();
if digits > 0 {
(
format!("g{}", &after_g[..digits]),
after_g[digits..].trim_start_matches(['•', '·', ' ', '\t']),
)
} else {
(String::new(), trimmed)
}
} else {
(String::new(), trimmed)
};
let intent = if task_id.is_empty() {
trimmed.to_string()
} else {
rest.split(['•', '·'])
.map(str::trim)
.filter(|part| !part.is_empty() && !part.starts_with("depth"))
.collect::<Vec<_>>()
.join(" • ")
};
TranscriptSubagentSummary {
label,
task_id,
intent,
status,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TranscriptEntry {
pub(super) kind: TranscriptEntryKind,
pub(super) rendered: String,
pub(super) body: String,
pub(super) tool: Option<TranscriptToolData>,
}
impl TranscriptEntry {
pub(super) fn from_parts(kind: TranscriptEntryKind, rendered: String, body: String) -> Self {
Self {
kind,
rendered,
body,
tool: None,
}
}
pub(crate) fn kind(&self) -> &TranscriptEntryKind {
&self.kind
}
pub(crate) fn rendered(&self) -> &str {
&self.rendered
}
pub(crate) fn body(&self) -> &str {
&self.body
}
pub(crate) fn tool_data(&self) -> Option<&TranscriptToolData> {
self.tool.as_ref()
}
pub(crate) fn user_prompt(text: &str) -> Self {
Self::prefixed(
TranscriptEntryKind::UserPrompt { automatic: false },
"you: ",
text,
)
}
pub(crate) fn automatic_user_prompt(text: &str) -> Self {
Self::prefixed(
TranscriptEntryKind::UserPrompt { automatic: true },
"you [automatic]: ",
text,
)
}
pub(crate) fn bash(command: &str) -> Self {
Self::prefixed(TranscriptEntryKind::Bash, "bash: ", command)
}
pub(crate) fn assistant(text: &str) -> Self {
Self::prefixed(TranscriptEntryKind::Assistant, "assistant: ", text)
}
pub(crate) fn thinking(text: &str) -> Self {
Self::prefixed(TranscriptEntryKind::Thinking, "thinking: ", text)
}
pub(crate) fn tool(body: String, data: TranscriptToolData) -> Self {
let mut entry = Self::prefixed(TranscriptEntryKind::Tool, "tool: ", &body);
entry.tool = Some(data);
entry
}
pub(crate) fn error(message: &str) -> Self {
let message = compact_error_message(&sanitize_preview(message));
Self::prefixed(TranscriptEntryKind::Error, "error: ", &message)
}
pub(crate) fn session(body: String) -> Self {
Self::prefixed(TranscriptEntryKind::Session, "session: ", &body)
}
pub(crate) fn compaction(body: String) -> Self {
Self::prefixed(TranscriptEntryKind::Compaction, "compact: ", &body)
}
pub(crate) fn canceled(prompt_preview: &str) -> Self {
let preview = sanitize_preview(prompt_preview);
let body = if preview.trim().is_empty() {
"prompt".to_string()
} else {
preview
};
Self::prefixed(TranscriptEntryKind::Canceled, "canceled: ", &body)
}
pub(crate) fn warning(message: &str) -> Self {
Self::prefixed(
TranscriptEntryKind::Diagnostic {
level: "warning".to_string(),
},
"warning: ",
message,
)
}
pub(crate) fn diagnostic(level: &str, body: &str) -> Self {
let level = sanitize_preview(level);
let body = sanitize_preview(&redact_sensitive_text(body));
let normalized = level.trim().to_ascii_lowercase();
let kind = match normalized.as_str() {
"error" => TranscriptEntryKind::Error,
"info" | "warn" | "warning" => TranscriptEntryKind::Diagnostic {
level: level.trim().to_string(),
},
_ => TranscriptEntryKind::LegacyDiagnostic,
};
let rendered = format!("{level}: {body}");
Self::from_parts(kind, rendered, body)
}
pub(crate) fn hook_diagnostic(message: &str) -> Self {
Self::prefixed(TranscriptEntryKind::HookDiagnostic, "hook: ", message)
}
pub(crate) fn provider_context(rendered: String) -> Self {
Self::unprefixed(TranscriptEntryKind::ProviderContextInjection, rendered)
}
pub(crate) fn subdir_instruction(rendered: String) -> Self {
Self::unprefixed(TranscriptEntryKind::SubdirInstruction, rendered)
}
fn prefixed(kind: TranscriptEntryKind, prefix: &str, body: &str) -> Self {
let body = sanitize_preview(body);
Self::from_parts(kind, format!("{prefix}{body}"), body)
}
fn unprefixed(kind: TranscriptEntryKind, rendered: String) -> Self {
let rendered = sanitize_preview(&rendered);
Self::from_parts(kind, rendered.clone(), rendered)
}
pub(crate) fn from_legacy(raw: String) -> Self {
let rendered = sanitize_preview(&raw);
if let Some(body) = rendered
.strip_prefix("you [automatic]: ")
.map(str::to_owned)
{
return Self::from_parts(
TranscriptEntryKind::UserPrompt { automatic: true },
rendered,
body,
);
}
if let Some(body) = rendered.strip_prefix("you: ").map(str::to_owned) {
return Self::from_parts(
TranscriptEntryKind::UserPrompt { automatic: false },
rendered,
body,
);
}
if let Some(body) = rendered.strip_prefix("bash: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::Bash, rendered, body);
}
if let Some(body) = rendered.strip_prefix("assistant: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::Assistant, rendered, body);
}
if let Some(body) = rendered.strip_prefix("thinking: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::Thinking, rendered, body);
}
if let Some(body) = rendered.strip_prefix("tool: ").map(str::to_owned) {
let data = legacy_tool_data(&body);
return Self::tool(body, data);
}
if let Some(body) = rendered.strip_prefix("error: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::Error, rendered, body);
}
if let Some(body) = rendered.strip_prefix("session: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::Session, rendered, body);
}
if let Some(body) = split_once_inline_separator(&rendered)
.filter(|(prefix, _)| *prefix == "session")
.map(|(_, body)| body.to_owned())
{
return Self::from_parts(TranscriptEntryKind::Session, rendered, body);
}
if let Some(body) = rendered.strip_prefix("compact: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::Compaction, rendered, body);
}
if let Some(body) = rendered.strip_prefix("hook: ").map(str::to_owned) {
return Self::from_parts(TranscriptEntryKind::HookDiagnostic, rendered, body);
}
if let Some(body) = rendered
.strip_prefix("canceled: ")
.or_else(|| rendered.strip_prefix("cancelled: "))
.map(str::to_owned)
{
return Self::from_parts(TranscriptEntryKind::Canceled, rendered, body);
}
if let Some((level, body)) = rendered
.split_once(": ")
.map(|(level, body)| (level.to_owned(), body.to_owned()))
{
let normalized = level.trim().to_ascii_lowercase();
if normalized == "error" {
return Self::from_parts(TranscriptEntryKind::Error, rendered, body);
}
if matches!(normalized.as_str(), "info" | "warn" | "warning") {
return Self::from_parts(
TranscriptEntryKind::Diagnostic {
level: level.trim().to_string(),
},
rendered,
body,
);
}
}
Self::from_parts(
TranscriptEntryKind::LegacyDiagnostic,
rendered.clone(),
rendered,
)
}
pub(crate) fn append_assistant_text(&mut self, text: &str) {
debug_assert!(self.kind.is_assistant());
self.body.push_str(text);
self.rendered.push_str(text);
}
pub(crate) fn append_thinking_text(&mut self, text: &str) {
debug_assert!(self.kind.is_thinking());
self.body.push_str(text);
self.rendered.push_str(text);
}
}
impl From<String> for TranscriptEntry {
fn from(value: String) -> Self {
Self::from_legacy(value)
}
}
impl From<&str> for TranscriptEntry {
fn from(value: &str) -> Self {
Self::from_legacy(value.to_string())
}
}
impl std::ops::Deref for TranscriptEntry {
type Target = str;
fn deref(&self) -> &Self::Target {
self.rendered()
}
}