use serde_json::Value;
use super::{
ThreadRenderNode, ThreadRenderSnapshot, render_node_id, set_inline_message_from_last_node,
upsert_node_by_id,
};
use crate::state::helpers::optional_string;
pub(super) fn push_terminal_interaction(snapshot: &mut ThreadRenderSnapshot, params: &Value) {
let turn_id = optional_string(params, "turnId");
let item_id = optional_string(params, "itemId");
let stdin = optional_string(params, "stdin").unwrap_or_default();
let waited = stdin.is_empty();
let command = item_id
.as_deref()
.and_then(|item_id| terminal_command_display(snapshot, item_id));
snapshot.nodes.push(ThreadRenderNode::TerminalInteraction {
id: transient_node_id(snapshot, turn_id.as_deref(), item_id.as_deref(), "terminal"),
turn_id,
item_id,
title: if waited {
"Waited for background terminal".to_string()
} else {
"Interacted with background terminal".to_string()
},
command,
stdin,
waited,
});
set_inline_message_from_last_node(snapshot);
}
pub(super) fn upsert_hook_event(
snapshot: &mut ThreadRenderSnapshot,
params: &Value,
started: bool,
) {
let turn_id = optional_string(params, "turnId");
let run = params.get("run").unwrap_or(&Value::Null);
let hook_id = run
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty());
let event_label = hook_event_label(
run.get("eventName")
.and_then(Value::as_str)
.unwrap_or_default(),
);
let status = if started {
"inProgress".to_string()
} else {
run.get("status")
.and_then(Value::as_str)
.unwrap_or("completed")
.to_string()
};
let node_id = hook_id
.map(|hook_id| render_node_id(turn_id.as_deref(), Some(hook_id), "hook"))
.unwrap_or_else(|| transient_node_id(snapshot, turn_id.as_deref(), hook_id, "hook"));
upsert_node_by_id(
snapshot,
ThreadRenderNode::HookEvent {
id: node_id,
turn_id,
item_id: hook_id.map(ToOwned::to_owned),
title: if started {
format!("Running {event_label} hook")
} else {
format!("{event_label} hook ({})", hook_status_label(&status))
},
state: status,
detail_lines: hook_detail_lines(run),
},
);
}
pub(super) fn upsert_approval_review(
snapshot: &mut ThreadRenderSnapshot,
params: &Value,
started: bool,
) {
let turn_id = optional_string(params, "turnId");
let item_id = optional_string(params, "targetItemId");
let review_id = params
.get("reviewId")
.and_then(Value::as_str)
.filter(|value| !value.is_empty());
let action = params.get("action").unwrap_or(&Value::Null);
let summary = approval_action_summary(action);
let review = params.get("review").unwrap_or(&Value::Null);
let state = review
.get("status")
.and_then(Value::as_str)
.unwrap_or(if started { "inProgress" } else { "completed" })
.to_string();
let node_id = review_id
.or(item_id.as_deref())
.map(|id| render_node_id(turn_id.as_deref(), Some(id), "approval-review"))
.unwrap_or_else(|| {
transient_node_id(
snapshot,
turn_id.as_deref(),
review_id.or(item_id.as_deref()),
"approval-review",
)
});
upsert_node_by_id(
snapshot,
ThreadRenderNode::ApprovalReview {
id: node_id,
turn_id,
item_id,
title: if started {
format!("Reviewing {summary}")
} else {
format!("Reviewed {summary}")
},
state,
detail_lines: approval_review_lines(params),
},
);
}
fn transient_node_id(
snapshot: &ThreadRenderSnapshot,
turn_id: Option<&str>,
item_id: Option<&str>,
suffix: &str,
) -> String {
format!(
"{}:{}:{}:{}",
turn_id.unwrap_or("turn"),
item_id.unwrap_or("item"),
suffix,
snapshot.revision + snapshot.nodes.len() as i64 + 1
)
}
fn terminal_command_display(snapshot: &ThreadRenderSnapshot, item_id: &str) -> Option<String> {
snapshot.nodes.iter().find_map(|node| match node {
ThreadRenderNode::ExecGroup {
item_id: Some(existing_item_id),
commands,
..
} if existing_item_id == item_id => {
let command = commands
.iter()
.map(|entry| entry.text.trim())
.filter(|entry| !entry.is_empty())
.collect::<Vec<_>>()
.join(" && ");
if command.is_empty() {
None
} else {
Some(command)
}
}
_ => None,
})
}
fn hook_event_label(event_name: &str) -> &'static str {
match event_name {
"preToolUse" => "PreToolUse",
"postToolUse" => "PostToolUse",
"sessionStart" => "SessionStart",
"userPromptSubmit" => "UserPromptSubmit",
"stop" => "Stop",
_ => "Hook",
}
}
fn hook_status_label(status: &str) -> &str {
match status {
"failed" => "failed",
"blocked" => "blocked",
"stopped" => "stopped",
"running" | "inProgress" => "running",
_ => "completed",
}
}
fn hook_detail_lines(run: &Value) -> Vec<String> {
let mut lines = Vec::new();
if let Some(message) = run.get("statusMessage").and_then(Value::as_str) {
let trimmed = message.trim();
if !trimmed.is_empty() {
lines.push(trimmed.to_string());
}
}
if let Some(entries) = run.get("entries").and_then(Value::as_array) {
for entry in entries {
let text = entry
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
if text.trim().is_empty() {
continue;
}
let prefix = match entry
.get("kind")
.and_then(Value::as_str)
.unwrap_or_default()
{
"warning" => "warning",
"stop" => "stop",
"feedback" => "feedback",
"context" => "hook context",
"error" => "error",
_ => "detail",
};
lines.push(format!("{prefix}: {text}"));
}
}
lines
}
fn approval_action_summary(action: &Value) -> String {
match action
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"command" | "execve" => "command approval request".to_string(),
"applyPatch" => "patch approval request".to_string(),
"network" => "network approval request".to_string(),
other if !other.is_empty() => format!("{other} approval request"),
_ => "approval request".to_string(),
}
}
fn approval_review_lines(params: &Value) -> Vec<String> {
let mut lines = Vec::new();
if let Some(action) = params.get("action")
&& let Some(summary) = approval_action_line(action)
{
lines.push(summary);
}
let review = params.get("review").unwrap_or(&Value::Null);
if let Some(status) = review.get("status").and_then(Value::as_str) {
lines.push(format!("status: {status}"));
}
if let Some(risk) = review.get("riskLevel").and_then(Value::as_str) {
lines.push(format!("risk: {risk}"));
}
if let Some(rationale) = review.get("rationale").and_then(Value::as_str) {
let trimmed = rationale.trim();
if !trimmed.is_empty() {
lines.push(trimmed.to_string());
}
}
if let Some(source) = params.get("decisionSource").and_then(Value::as_str) {
lines.push(format!("decision source: {source}"));
}
lines
}
fn approval_action_line(action: &Value) -> Option<String> {
match action
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"command" => {
let command = action
.get("command")
.and_then(Value::as_str)
.unwrap_or_default();
let cwd = action
.get("cwd")
.and_then(Value::as_str)
.unwrap_or_default();
if command.is_empty() {
None
} else if cwd.is_empty() {
Some(format!("command: {command}"))
} else {
Some(format!("command: {command} @ {cwd}"))
}
}
"execve" => {
let program = action
.get("program")
.and_then(Value::as_str)
.unwrap_or_default();
let argv = action
.get("argv")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
let command = if argv.is_empty() {
program.to_string()
} else {
argv
};
if command.is_empty() {
None
} else {
Some(format!("command: {command}"))
}
}
"applyPatch" => {
let files = action
.get("files")
.and_then(Value::as_array)
.map(|items| items.iter().filter_map(Value::as_str).collect::<Vec<_>>())
.unwrap_or_default();
if files.is_empty() {
Some("patch".to_string())
} else if files.len() == 1 {
Some(format!("patch: {}", files[0]))
} else {
Some(format!("patch: {} files", files.len()))
}
}
"network" => {
let host = action
.get("host")
.and_then(Value::as_str)
.unwrap_or_default();
if host.is_empty() {
Some("network access".to_string())
} else {
Some(format!("network: {host}"))
}
}
_ => None,
}
}