use crate::Client;
use crate::message::{ConversationMessage, Entry, MessageKind, ToolResultData};
use serde_json::{Value, json};
use std::collections::HashMap;
#[must_use]
pub fn render(
client: Client,
entries: &[Entry],
messages: &[ConversationMessage],
context_id: &str,
cwd: Option<&str>,
) -> String {
match client {
Client::Claude => render_claude(entries, messages, context_id, cwd),
Client::Codex => render_codex(messages, context_id, cwd),
Client::Crush => render_crush(messages, context_id),
Client::Gemini => render_gemini(messages, context_id),
Client::Goose => render_goose(messages, context_id, cwd),
Client::Opencode => render_opencode(entries, messages, context_id),
Client::Pi => render_pi(entries, messages, context_id, cwd),
}
}
fn parent_map(entries: &[Entry]) -> HashMap<&str, &str> {
entries
.iter()
.map(|e| (e.id.as_str(), e.parent_id.as_str()))
.collect()
}
fn parent_value(parent: &str) -> Value {
if parent.is_empty() {
Value::Null
} else {
Value::String(parent.to_string())
}
}
fn to_jsonl(lines: &[String]) -> String {
if lines.is_empty() {
return String::new();
}
lines.join("\n") + "\n"
}
fn seq_index(idx: usize) -> i64 {
i64::try_from(idx).unwrap_or_default()
}
fn synthetic_timestamp(idx: usize) -> String {
const BASE: i64 = 1_767_225_600; chrono::DateTime::from_timestamp(BASE + seq_index(idx), 0).map_or_else(String::new, |dt| {
dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()
})
}
enum Turn<'a> {
Plain(&'a ConversationMessage),
Assistant {
msg: &'a ConversationMessage,
results: Vec<&'a ConversationMessage>,
},
}
fn group_turns(messages: &[ConversationMessage]) -> Vec<Turn<'_>> {
let mut turns = Vec::new();
let mut i = 0;
while i < messages.len() {
if matches!(messages[i].kind, MessageKind::AssistantResponse(_)) {
let mut results = Vec::new();
let mut j = i + 1;
while j < messages.len()
&& matches!(
messages[j].kind,
MessageKind::ToolResultData(_) | MessageKind::BashOutput(_)
)
{
results.push(&messages[j]);
j += 1;
}
turns.push(Turn::Assistant {
msg: &messages[i],
results,
});
i = j;
} else {
turns.push(Turn::Plain(&messages[i]));
i += 1;
}
}
turns
}
fn bash_text(command: &str, output: &str) -> String {
format!("$ {command}\n{output}")
}
fn claude_message_value(msg: &ConversationMessage) -> Value {
match &msg.kind {
MessageKind::TextContent(tc) => json!({"role": tc.role, "content": tc.text}),
MessageKind::AssistantResponse(ar) => {
let mut content: Vec<Value> = Vec::new();
for t in &ar.thinking {
content.push(json!({"type": "thinking", "thinking": t, "signature": ""}));
}
if !ar.text.is_empty() {
content.push(json!({"type": "text", "text": ar.text}));
}
for tc in &ar.tool_calls {
content.push(
json!({"type": "tool_use", "id": tc.id, "name": tc.name, "input": tc.arguments}),
);
}
json!({"role": "assistant", "content": content})
}
MessageKind::ToolResultData(tr) => {
let content = if tr.is_error {
json!(tr.content)
} else {
json!([{"type": "text", "text": tr.content}])
};
json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tr.call_id,
"content": content,
"is_error": tr.is_error
}]
})
}
MessageKind::BashOutput(bo) => json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "bash",
"content": [{"type": "text", "text": bash_text(&bo.command, &bo.output)}],
"is_error": false
}]
}),
}
}
fn render_claude(
entries: &[Entry],
messages: &[ConversationMessage],
context_id: &str,
cwd: Option<&str>,
) -> String {
let parents = parent_map(entries);
let lines: Vec<String> = messages
.iter()
.map(|msg| {
let parent = parents.get(msg.entry_id.as_str()).copied().unwrap_or("");
let record_type = match &msg.kind {
MessageKind::AssistantResponse(_) => "assistant",
_ => "user",
};
serde_json::to_string(&json!({
"type": record_type,
"uuid": msg.entry_id,
"parentUuid": parent_value(parent),
"sessionId": context_id,
"cwd": cwd.unwrap_or(""),
"message": claude_message_value(msg)
}))
.expect("serialize claude entry")
})
.collect();
to_jsonl(&lines)
}
fn render_pi(
entries: &[Entry],
messages: &[ConversationMessage],
context_id: &str,
cwd: Option<&str>,
) -> String {
let mut lines = vec![
serde_json::to_string(
&json!({"type": "session", "id": context_id, "cwd": cwd.unwrap_or(".")}),
)
.expect("serialize pi header"),
];
let parents = parent_map(entries);
for msg in messages {
let parent = parents.get(msg.entry_id.as_str()).copied().unwrap_or("");
let message = match &msg.kind {
MessageKind::TextContent(tc) => {
json!({"role": tc.role, "content": [{"type": "text", "text": tc.text}]})
}
MessageKind::AssistantResponse(ar) => {
let mut parts: Vec<Value> = Vec::new();
for t in &ar.thinking {
parts.push(json!({"type": "thinking", "text": t}));
}
if !ar.text.is_empty() {
parts.push(json!({"type": "text", "text": ar.text}));
}
for tc in &ar.tool_calls {
parts.push(
json!({"type": "toolCall", "name": tc.name, "arguments": tc.arguments}),
);
}
json!({"role": "assistant", "content": parts})
}
MessageKind::ToolResultData(tr) => {
let mut obj =
json!({"role": "toolResult", "toolName": tr.tool_name, "content": tr.content});
if tr.is_error {
obj["isError"] = json!(true);
}
obj
}
MessageKind::BashOutput(bo) => json!({
"role": "bashExecution",
"command": bo.command,
"content": [{"type": "text", "text": bo.output}]
}),
};
lines.push(
serde_json::to_string(&json!({
"id": msg.entry_id,
"parentId": parent,
"type": "message",
"message": message
}))
.expect("serialize pi entry"),
);
}
to_jsonl(&lines)
}
fn goose_blocks(msg: &ConversationMessage) -> Vec<Value> {
match &msg.kind {
MessageKind::TextContent(tc) => vec![json!({"type": "text", "text": tc.text})],
MessageKind::AssistantResponse(ar) => {
let mut content: Vec<Value> = Vec::new();
for t in &ar.thinking {
content.push(json!({"type": "thinking", "thinking": t, "signature": ""}));
}
if !ar.text.is_empty() {
content.push(json!({"type": "text", "text": ar.text}));
}
for tc in &ar.tool_calls {
content.push(json!({
"type": "toolRequest",
"id": tc.id,
"toolCall": {"status": "success", "value": {"name": tc.name, "arguments": tc.arguments}}
}));
}
content
}
MessageKind::ToolResultData(tr) => {
let status = if tr.is_error { "error" } else { "success" };
vec![json!({
"type": "toolResponse",
"id": tr.call_id,
"toolResult": {
"status": status,
"value": {"name": tr.tool_name, "content": [{"type": "text", "text": tr.content}]}
}
})]
}
MessageKind::BashOutput(bo) => {
vec![json!({"type": "text", "text": bash_text(&bo.command, &bo.output)})]
}
}
}
fn render_goose(messages: &[ConversationMessage], context_id: &str, cwd: Option<&str>) -> String {
let msg_rows: Vec<Value> = messages
.iter()
.enumerate()
.map(|(idx, msg)| {
json!({
"message_id": msg.entry_id,
"session_id": context_id,
"role": msg.role(),
"content_json": goose_blocks(msg),
"created_timestamp": seq_index(idx)
})
})
.collect();
let session = json!({
"id": context_id,
"name": "",
"working_dir": cwd.unwrap_or(""),
"updated_at": ""
});
serde_json::to_string_pretty(&json!({"session": session, "messages": msg_rows}))
.expect("serialize goose")
+ "\n"
}
fn opencode_parts(msg: &ConversationMessage) -> Vec<Value> {
match &msg.kind {
MessageKind::TextContent(tc) => vec![json!({"type": "text", "text": tc.text})],
MessageKind::AssistantResponse(ar) => {
let mut parts: Vec<Value> = Vec::new();
if !ar.text.is_empty() {
parts.push(json!({"type": "text", "text": ar.text}));
}
for t in &ar.thinking {
parts.push(json!({"type": "reasoning", "text": t}));
}
for tc in &ar.tool_calls {
parts.push(json!({"type": "tool", "name": tc.name, "arguments": tc.arguments}));
}
parts
}
MessageKind::ToolResultData(tr) => vec![json!({"type": "text", "text": tr.content})],
MessageKind::BashOutput(bo) => {
vec![json!({"type": "text", "text": bash_text(&bo.command, &bo.output)})]
}
}
}
fn render_opencode(
entries: &[Entry],
messages: &[ConversationMessage],
context_id: &str,
) -> String {
let mut msg_rows: Vec<Value> = Vec::new();
let mut part_rows: Vec<Value> = Vec::new();
let parents = parent_map(entries);
for (idx, msg) in messages.iter().enumerate() {
let parent = parents.get(msg.entry_id.as_str()).copied().unwrap_or("");
msg_rows.push(json!({
"session_id": context_id,
"id": msg.entry_id,
"data": {"role": msg.role(), "id": msg.entry_id, "parentID": parent},
"time_created": seq_index(idx)
}));
for part in opencode_parts(msg) {
part_rows.push(json!({
"session_id": context_id,
"message_id": msg.entry_id,
"data": part,
"time_created": seq_index(idx)
}));
}
}
serde_json::to_string_pretty(&json!({"messages": msg_rows, "parts": part_rows}))
.expect("serialize opencode")
+ "\n"
}
fn crush_tool_result_part(tr: &ToolResultData) -> Value {
json!({
"type": "tool_result",
"call_id": tr.call_id,
"name": tr.tool_name,
"content": tr.content,
"isError": tr.is_error
})
}
fn crush_parts(msg: &ConversationMessage, results: &[&ConversationMessage]) -> Vec<Value> {
let mut parts: Vec<Value> = Vec::new();
match &msg.kind {
MessageKind::TextContent(tc) => parts.push(json!({"type": "text", "text": tc.text})),
MessageKind::AssistantResponse(ar) => {
if !ar.text.is_empty() {
parts.push(json!({"type": "text", "text": ar.text}));
}
for tc in &ar.tool_calls {
parts.push(
json!({"type": "tool_use", "id": tc.id, "name": tc.name, "input": tc.arguments}),
);
}
}
MessageKind::ToolResultData(tr) => parts.push(crush_tool_result_part(tr)),
MessageKind::BashOutput(bo) => {
parts.push(json!({"type": "text", "text": bash_text(&bo.command, &bo.output)}));
}
}
for result in results {
match &result.kind {
MessageKind::ToolResultData(tr) => parts.push(crush_tool_result_part(tr)),
MessageKind::BashOutput(bo) => parts.push(json!({
"type": "tool_result",
"name": "bash",
"content": bash_text(&bo.command, &bo.output),
"isError": false
})),
_ => {}
}
}
parts
}
fn render_crush(messages: &[ConversationMessage], context_id: &str) -> String {
let mut rows: Vec<Value> = Vec::new();
for (idx, turn) in group_turns(messages).into_iter().enumerate() {
let (msg, results): (&ConversationMessage, Vec<&ConversationMessage>) = match turn {
Turn::Plain(m) => (m, Vec::new()),
Turn::Assistant { msg, results } => (msg, results),
};
let role = match &msg.kind {
MessageKind::TextContent(tc) => tc.role.clone(),
MessageKind::AssistantResponse(_)
| MessageKind::ToolResultData(_)
| MessageKind::BashOutput(_) => "assistant".to_string(),
};
rows.push(json!({
"session_id": context_id,
"role": role,
"parts": crush_parts(msg, &results),
"created_at": synthetic_timestamp(idx)
}));
}
serde_json::to_string_pretty(&json!({"messages": rows})).expect("serialize crush") + "\n"
}
fn gemini_thoughts(thinking: &[String]) -> Vec<Value> {
thinking
.iter()
.map(|t| json!({"subject": "", "description": t}))
.collect()
}
fn gemini_result_parts(call_id: &str, name: &str, content: &str, is_error: bool) -> Value {
let response = if is_error {
json!({"error": content})
} else {
json!({"output": content})
};
json!([{"functionResponse": {"id": call_id, "name": name, "response": response}}])
}
fn gemini_result_of(msg: &ConversationMessage) -> (&'static str, Value) {
match &msg.kind {
MessageKind::ToolResultData(tr) => {
let status = if tr.is_error { "error" } else { "success" };
(
status,
gemini_result_parts(&tr.call_id, &tr.tool_name, &tr.content, tr.is_error),
)
}
MessageKind::BashOutput(bo) => (
"success",
gemini_result_parts("", "bash", &bash_text(&bo.command, &bo.output), false),
),
_ => ("success", Value::Null),
}
}
fn gemini_result_call_id(msg: &ConversationMessage) -> &str {
match &msg.kind {
MessageKind::ToolResultData(tr) => tr.call_id.as_str(),
_ => "",
}
}
fn gemini_plain_value(msg: &ConversationMessage) -> Value {
match &msg.kind {
MessageKind::TextContent(tc) => {
let msg_type = if tc.role == "assistant" {
"gemini"
} else {
tc.role.as_str()
};
json!({"id": msg.entry_id, "type": msg_type, "content": tc.text})
}
MessageKind::AssistantResponse(ar) => {
let tool_calls: Vec<Value> = ar
.tool_calls
.iter()
.map(|tc| json!({"id": tc.id, "name": tc.name, "args": tc.arguments, "status": "success"}))
.collect();
json!({
"id": msg.entry_id,
"type": "gemini",
"content": ar.text,
"thoughts": gemini_thoughts(&ar.thinking),
"toolCalls": tool_calls
})
}
MessageKind::ToolResultData(tr) => {
let content = if tr.is_error {
format!("{} (error): {}", tr.tool_name, tr.content)
} else {
format!("{}: {}", tr.tool_name, tr.content)
};
json!({"id": msg.entry_id, "type": "user", "content": content})
}
MessageKind::BashOutput(bo) => json!({
"id": msg.entry_id,
"type": "user",
"content": bash_text(&bo.command, &bo.output)
}),
}
}
fn gemini_assistant_value<'a>(
msg: &'a ConversationMessage,
results: &[&'a ConversationMessage],
) -> (Value, Vec<&'a ConversationMessage>) {
let MessageKind::AssistantResponse(ar) = &msg.kind else {
return (gemini_plain_value(msg), Vec::new());
};
let mut used = vec![false; results.len()];
let tool_calls: Vec<Value> = ar
.tool_calls
.iter()
.map(|tc| {
let pos = if tc.id.is_empty() {
(0..results.len()).find(|&k| !used[k])
} else {
results
.iter()
.enumerate()
.position(|(k, m)| !used[k] && gemini_result_call_id(m) == tc.id)
};
let mut call = json!({"id": tc.id, "name": tc.name, "args": tc.arguments});
if let Some(p) = pos {
used[p] = true;
let (status, result) = gemini_result_of(results[p]);
call["status"] = json!(status);
call["result"] = result;
} else {
call["status"] = json!("success");
}
call
})
.collect();
let value = json!({
"id": msg.entry_id,
"type": "gemini",
"content": ar.text,
"thoughts": gemini_thoughts(&ar.thinking),
"toolCalls": tool_calls
});
let leftover = results
.iter()
.enumerate()
.filter_map(|(k, m)| (!used[k]).then_some(*m))
.collect();
(value, leftover)
}
fn render_gemini(messages: &[ConversationMessage], context_id: &str) -> String {
let mut msgs: Vec<Value> = Vec::new();
for turn in group_turns(messages) {
match turn {
Turn::Plain(msg) => msgs.push(gemini_plain_value(msg)),
Turn::Assistant { msg, results } => {
let (value, leftover) = gemini_assistant_value(msg, &results);
msgs.push(value);
for result in leftover {
msgs.push(gemini_plain_value(result));
}
}
}
}
serde_json::to_string_pretty(&json!({"sessionId": context_id, "messages": msgs}))
.expect("serialize gemini")
+ "\n"
}
fn codex_sub_id(entry_id: &str, sub: usize) -> String {
if sub == 0 {
entry_id.to_string()
} else {
format!("{entry_id}-{sub}")
}
}
fn codex_message_line(id: &str, role: &str, content_type: &str, text: &str) -> String {
serde_json::to_string(&json!({
"type": "response_item",
"payload": {
"type": "message",
"id": id,
"role": role,
"content": [{"type": content_type, "text": text}]
}
}))
.expect("serialize codex message")
}
fn codex_push_lines(msg: &ConversationMessage, lines: &mut Vec<String>) {
match &msg.kind {
MessageKind::TextContent(tc) => {
let content_type = if tc.role == "user" {
"input_text"
} else {
"output_text"
};
lines.push(codex_message_line(
&msg.entry_id,
&tc.role,
content_type,
&tc.text,
));
}
MessageKind::AssistantResponse(ar) => {
let mut sub = 0usize;
if !ar.thinking.is_empty() {
let id = codex_sub_id(&msg.entry_id, sub);
sub += 1;
let summary: Vec<Value> = ar
.thinking
.iter()
.map(|t| json!({"type": "summary_text", "text": t}))
.collect();
lines.push(
serde_json::to_string(&json!({
"type": "response_item",
"payload": {"type": "reasoning", "id": id, "summary": summary}
}))
.expect("serialize codex reasoning"),
);
}
if !ar.text.is_empty() || (ar.thinking.is_empty() && ar.tool_calls.is_empty()) {
let id = codex_sub_id(&msg.entry_id, sub);
sub += 1;
lines.push(codex_message_line(
&id,
"assistant",
"output_text",
&ar.text,
));
}
for tc in &ar.tool_calls {
let call_id = if tc.id.is_empty() {
codex_sub_id(&msg.entry_id, sub)
} else {
tc.id.clone()
};
sub += 1;
let arguments =
serde_json::to_string(&tc.arguments).expect("serialize codex arguments");
lines.push(
serde_json::to_string(&json!({
"type": "response_item",
"payload": {
"type": "function_call",
"call_id": call_id,
"name": tc.name,
"arguments": arguments
}
}))
.expect("serialize codex function_call"),
);
}
}
MessageKind::ToolResultData(tr) => {
let call_id = if tr.call_id.is_empty() {
tr.tool_name.as_str()
} else {
tr.call_id.as_str()
};
lines.push(
serde_json::to_string(&json!({
"type": "response_item",
"payload": {
"type": "function_call_output",
"call_id": call_id,
"output": tr.content
}
}))
.expect("serialize codex output"),
);
}
MessageKind::BashOutput(bo) => {
lines.push(
serde_json::to_string(&json!({
"type": "response_item",
"payload": {
"type": "function_call_output",
"call_id": "bash",
"output": bash_text(&bo.command, &bo.output)
}
}))
.expect("serialize codex bash output"),
);
}
}
}
fn render_codex(messages: &[ConversationMessage], context_id: &str, cwd: Option<&str>) -> String {
let mut lines = vec![
serde_json::to_string(&json!({
"type": "session_meta",
"payload": {"id": context_id, "cwd": cwd.unwrap_or("")}
}))
.expect("serialize codex header"),
];
for msg in messages {
codex_push_lines(msg, &mut lines);
}
to_jsonl(&lines)
}