use serde_json::{Value, json};
const TIMELINE_SCHEMA_VERSION: i64 = 4;
#[derive(Clone, Debug)]
pub(super) struct TimelineSemanticInfo {
pub(super) kind: String,
pub(super) detail: Option<String>,
pub(super) confidence: String,
pub(super) role: String,
}
impl TimelineSemanticInfo {
pub(super) fn new(
kind: impl Into<String>,
detail: Option<String>,
confidence: impl Into<String>,
role: impl Into<String>,
) -> Self {
Self {
kind: kind.into(),
detail,
confidence: confidence.into(),
role: role.into(),
}
}
pub(super) fn to_value(&self) -> Value {
json!({
"kind": self.kind,
"detail": self.detail,
"confidence": self.confidence,
"role": self.role,
})
}
pub(super) fn from_value(value: &Value) -> Option<Self> {
let object = value.as_object()?;
Some(Self {
kind: object.get("kind")?.as_str()?.to_string(),
detail: object
.get("detail")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
confidence: object
.get("confidence")
.and_then(Value::as_str)
.unwrap_or("low")
.to_string(),
role: object
.get("role")
.and_then(Value::as_str)
.unwrap_or("primary")
.to_string(),
})
}
}
#[derive(Clone, Debug)]
pub(super) struct TimelineLifecycleInfo {
stage: String,
source_status: Option<String>,
has_visible_content: bool,
}
impl TimelineLifecycleInfo {
pub(super) fn new(
stage: impl Into<String>,
source_status: Option<String>,
has_visible_content: bool,
) -> Self {
Self {
stage: stage.into(),
source_status,
has_visible_content,
}
}
fn to_value(&self) -> Value {
json!({
"stage": self.stage,
"sourceStatus": self.source_status,
"hasVisibleContent": self.has_visible_content,
})
}
}
#[derive(Clone, Debug, Default)]
pub(super) struct TimelineSummaryInfo {
pub(super) title: Option<String>,
pub(super) label: Option<String>,
pub(super) command: Option<String>,
pub(super) query: Option<String>,
pub(super) targets: Vec<String>,
pub(super) primary_path: Option<String>,
pub(super) file_count: Option<i64>,
pub(super) add_lines: Option<i64>,
pub(super) remove_lines: Option<i64>,
pub(super) wait_count: Option<i64>,
}
impl TimelineSummaryInfo {
pub(super) fn to_value(&self) -> Value {
json!({
"title": self.title,
"label": self.label,
"command": self.command,
"query": self.query,
"targets": self.targets,
"primaryPath": self.primary_path,
"fileCount": self.file_count,
"addLines": self.add_lines,
"removeLines": self.remove_lines,
"waitCount": self.wait_count,
})
}
pub(super) fn from_value(value: &Value) -> Option<Self> {
let object = value.as_object()?;
Some(Self {
title: object
.get("title")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
label: object
.get("label")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
command: object
.get("command")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
query: object
.get("query")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
targets: object
.get("targets")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default(),
primary_path: object
.get("primaryPath")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
file_count: object.get("fileCount").and_then(Value::as_i64),
add_lines: object.get("addLines").and_then(Value::as_i64),
remove_lines: object.get("removeLines").and_then(Value::as_i64),
wait_count: object.get("waitCount").and_then(Value::as_i64),
})
}
}
pub(super) fn timeline_render_kind(raw_type: &str) -> &'static str {
match raw_type {
"userMessage" => "user",
"agentMessage" | "message" => "agent",
"reasoning" | "reasoning_text" | "summary_text" => "thinking",
"plan" => "plan",
"commandExecution"
| "mcpToolCall"
| "dynamicToolCall"
| "webSearch"
| "imageGeneration"
| "imageView"
| "collabToolCall"
| "collabAgentToolCall"
| "function_call"
| "function_call_output"
| "custom_tool_call"
| "custom_tool_call_output"
| "file_search_call"
| "web_search_call"
| "computer_call"
| "computer_call_output"
| "code_interpreter_call"
| "shell_call"
| "shell_call_output"
| "local_shell_call"
| "local_shell_call_output"
| "apply_patch_call"
| "apply_patch_call_output"
| "image_generation_call"
| "mcp_call"
| "mcp_approval_request"
| "mcp_approval_response"
| "mcp_list_tools"
| "tool_search_output" => "tool",
"fileChange" | "diff" => "diff",
"hookPrompt" | "contextCompaction" | "enteredReviewMode" | "exitedReviewMode"
| "compaction" => "system",
_ => "fallback",
}
}
pub(super) fn timeline_collapse_hint(render_kind: &str, _raw_type: &str) -> Value {
match render_kind {
"user" | "agent" => json!({
"enabled": false,
"preferCollapsed": false,
"lineThreshold": 0,
"charThreshold": 0,
}),
"tool" | "thinking" => json!({
"enabled": true,
"preferCollapsed": true,
"lineThreshold": 8,
"charThreshold": 320,
}),
"diff" => json!({
"enabled": true,
"preferCollapsed": true,
"lineThreshold": 12,
"charThreshold": 480,
}),
_ => json!({
"enabled": true,
"preferCollapsed": false,
"lineThreshold": 8,
"charThreshold": 320,
}),
}
}
pub(super) fn timeline_stream_metadata(
is_streaming: bool,
authoritative: bool,
phase: Option<String>,
summary_index: Option<i64>,
content_index: Option<i64>,
) -> Value {
json!({
"isStreaming": is_streaming,
"authoritative": authoritative,
"phase": phase,
"summaryIndex": summary_index,
"contentIndex": content_index,
})
}
pub(super) fn timeline_lifecycle_info(
authoritative: bool,
status: Option<&str>,
has_visible_content: bool,
) -> TimelineLifecycleInfo {
let source_status = status
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let normalized_status = source_status.as_deref().map(|value| value.to_lowercase());
let stage = if authoritative {
match normalized_status.as_deref() {
Some("failed") => "failed",
Some("declined") => "declined",
_ => "completed",
}
} else if has_visible_content {
"streaming"
} else {
"waiting"
};
TimelineLifecycleInfo::new(stage, source_status, has_visible_content)
}
pub(super) fn timeline_has_visible_content(
text: &str,
payload: &Value,
summary: Option<&TimelineSummaryInfo>,
) -> bool {
!text.trim().is_empty()
|| timeline_payload_has_visible_content(payload)
|| summary.is_some_and(summary_has_visible_content)
}
fn timeline_payload_has_visible_content(value: &Value) -> bool {
match value {
Value::Null => false,
Value::Bool(flag) => *flag,
Value::Number(_) => true,
Value::String(text) => !text.trim().is_empty(),
Value::Array(items) => items.iter().any(timeline_payload_has_visible_content),
Value::Object(object) => object.iter().any(|(key, child)| {
matches!(
key.as_str(),
"text"
| "content"
| "summary"
| "output"
| "result"
| "error"
| "stdout"
| "stderr"
| "changes"
| "query"
| "path"
| "targets"
| "command"
| "review"
| "diff"
| "explanation"
| "label"
| "title"
| "primaryPath"
) && timeline_payload_has_visible_content(child)
}),
}
}
fn summary_has_visible_content(summary: &TimelineSummaryInfo) -> bool {
summary
.title
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
|| summary
.label
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
|| summary
.command
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
|| summary
.query
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
|| summary
.primary_path
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
|| !summary.targets.is_empty()
|| summary.file_count.unwrap_or_default() > 0
|| summary.add_lines.unwrap_or_default() > 0
|| summary.remove_lines.unwrap_or_default() > 0
|| summary.wait_count.unwrap_or_default() > 0
}
pub(super) fn timeline_metadata(
source_kind: &str,
raw_type: &str,
render_kind: &str,
collapse_hint: Value,
stream: Value,
lifecycle: Option<&TimelineLifecycleInfo>,
payload: Value,
semantic: Option<&TimelineSemanticInfo>,
summary: Option<&TimelineSummaryInfo>,
wire: Value,
) -> Value {
json!({
"schemaVersion": TIMELINE_SCHEMA_VERSION,
"sourceKind": source_kind,
"rawType": raw_type,
"renderKind": render_kind,
"collapseHint": collapse_hint,
"stream": stream,
"lifecycle": lifecycle.map(TimelineLifecycleInfo::to_value).unwrap_or(Value::Null),
"payload": payload,
"wire": wire,
"semantic": semantic.map(TimelineSemanticInfo::to_value).unwrap_or(Value::Null),
"summary": summary.map(TimelineSummaryInfo::to_value).unwrap_or(Value::Null),
})
}