use crate::Client;
use crate::message::{ConversationMessage, Entry, MessageKind};
use serde_json::{Value, json};
#[must_use]
pub fn render(
client: Client,
entries: &[Entry],
messages: &[ConversationMessage],
context_id: &str,
) -> String {
match client {
Client::Claude => render_claude(entries, messages),
Client::Codex => render_codex(messages, context_id),
Client::Crush => render_crush(messages),
Client::Gemini => render_gemini(messages),
Client::Goose => render_goose(messages),
Client::Opencode => render_opencode(entries, messages),
Client::Pi => render_pi(entries, messages, context_id),
}
}
fn parent_id<'a>(entries: &'a [Entry], id: &str) -> &'a str {
entries
.iter()
.find(|e| e.id == id)
.map_or("", |e| e.parent_id.as_str())
}
fn to_jsonl(lines: &[String]) -> String {
if lines.is_empty() {
return String::new();
}
lines.join("\n") + "\n"
}
fn claude_message_value(msg: &ConversationMessage) -> Value {
match &msg.kind {
MessageKind::TextContent(tc) => json!({
"role": tc.role,
"content": [{"type": "text", "text": tc.text}]
}),
MessageKind::AssistantResponse(ar) => {
let mut content: Vec<Value> = Vec::new();
if !ar.text.is_empty() {
content.push(json!({"type": "text", "text": ar.text}));
}
for t in &ar.thinking {
content.push(json!({"type": "thinking", "thinking": t}));
}
for tc in &ar.tool_calls {
content.push(
json!({"type": "tool_use", "id": "", "name": tc.name, "input": tc.arguments}),
);
}
json!({"role": "assistant", "content": content})
}
MessageKind::ToolResultData(tr) => json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tr.tool_name,
"content": [{"type": "text", "text": tr.content}],
"is_error": tr.is_error
}]
}),
MessageKind::BashOutput(bo) => json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "bash",
"content": [{"type": "text", "text": format!("$ {}\n{}", bo.command, bo.output)}],
"is_error": false
}]
}),
}
}
fn render_claude(entries: &[Entry], messages: &[ConversationMessage]) -> String {
let lines: Vec<String> = messages
.iter()
.map(|msg| {
let parent = parent_id(entries, &msg.entry_id);
serde_json::to_string(&json!({
"uuid": msg.entry_id,
"parentUuid": parent,
"message": claude_message_value(msg)
}))
.expect("serialize claude entry")
})
.collect();
to_jsonl(&lines)
}
fn render_pi(entries: &[Entry], messages: &[ConversationMessage], context_id: &str) -> String {
let mut lines = vec![
serde_json::to_string(&json!({"type": "session", "id": context_id, "cwd": "."}))
.expect("serialize pi header"),
];
for msg in messages {
let parent = parent_id(entries, &msg.entry_id);
let message = match &msg.kind {
MessageKind::TextContent(tc) => json!({"role": tc.role, "content": 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": "thinking", "text": t}));
}
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) => json!({
"role": "toolResult",
"toolName": tr.tool_name,
"content": tr.content,
"isError": tr.is_error
}),
MessageKind::BashOutput(bo) => json!({
"role": "bashExecution",
"command": bo.command,
"content": 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_content_value(msg: &ConversationMessage) -> Value {
match &msg.kind {
MessageKind::TextContent(tc) => json!([{"type": "text", "text": tc.text}]),
MessageKind::AssistantResponse(ar) => {
let mut content: Vec<Value> = Vec::new();
if !ar.text.is_empty() {
content.push(json!({"type": "text", "text": ar.text}));
}
for t in &ar.thinking {
content.push(json!({"type": "thinking", "thinking": t}));
}
for tc in &ar.tool_calls {
content.push(json!({
"type": "toolRequest",
"toolCall": {"value": {"name": tc.name, "arguments": tc.arguments}}
}));
}
json!(content)
}
MessageKind::ToolResultData(tr) => {
let status = if tr.is_error { "error" } else { "success" };
json!([{
"type": "toolResponse",
"toolResult": {
"status": status,
"value": {
"name": tr.tool_name,
"content": [{"type": "text", "text": tr.content}]
}
}
}])
}
MessageKind::BashOutput(bo) => {
json!([{"type": "text", "text": format!("$ {}\n{}", bo.command, bo.output)}])
}
}
}
fn render_goose(messages: &[ConversationMessage]) -> String {
let arr: Vec<Value> = messages
.iter()
.map(|msg| {
let role = match &msg.kind {
MessageKind::TextContent(tc) => tc.role.as_str(),
MessageKind::AssistantResponse(_) => "assistant",
MessageKind::ToolResultData(_) | MessageKind::BashOutput(_) => "user",
};
json!({"role": role, "content": goose_content_value(msg)})
})
.collect();
serde_json::to_string_pretty(&json!(arr)).expect("serialize goose") + "\n"
}
fn render_crush(messages: &[ConversationMessage]) -> String {
render_goose(messages)
}
fn opencode_push_parts(msg: &ConversationMessage, parts: &mut Vec<Value>) {
match &msg.kind {
MessageKind::TextContent(tc) => {
parts.push(json!({"message_id": msg.entry_id, "type": "text", "text": tc.text}));
}
MessageKind::AssistantResponse(ar) => {
if !ar.text.is_empty() {
parts.push(json!({"message_id": msg.entry_id, "type": "text", "text": ar.text}));
}
for t in &ar.thinking {
parts.push(json!({"message_id": msg.entry_id, "type": "reasoning", "text": t}));
}
for tc in &ar.tool_calls {
parts.push(json!({
"message_id": msg.entry_id,
"type": "tool",
"name": tc.name,
"arguments": tc.arguments
}));
}
}
MessageKind::ToolResultData(tr) => {
parts.push(
json!({"message_id": msg.entry_id, "type": "tool-result", "content": tr.content}),
);
}
MessageKind::BashOutput(bo) => {
parts.push(json!({
"message_id": msg.entry_id,
"type": "tool-result",
"content": format!("$ {}\n{}", bo.command, bo.output)
}));
}
}
}
fn render_opencode(entries: &[Entry], messages: &[ConversationMessage]) -> String {
let mut msg_records: Vec<Value> = Vec::new();
let mut part_records: Vec<Value> = Vec::new();
for msg in messages {
let parent = parent_id(entries, &msg.entry_id);
let role = match &msg.kind {
MessageKind::TextContent(tc) => tc.role.as_str(),
MessageKind::AssistantResponse(_) => "assistant",
MessageKind::ToolResultData(_) | MessageKind::BashOutput(_) => "user",
};
msg_records.push(json!({"id": msg.entry_id, "role": role, "parentID": parent}));
opencode_push_parts(msg, &mut part_records);
}
serde_json::to_string_pretty(&json!({"messages": msg_records, "parts": part_records}))
.expect("serialize opencode")
+ "\n"
}
fn render_gemini(messages: &[ConversationMessage]) -> String {
let msgs: Vec<Value> = messages
.iter()
.map(|msg| match &msg.kind {
MessageKind::TextContent(tc) => {
let msg_type = if tc.role == "assistant" {
"gemini"
} else {
"user"
};
json!({"id": msg.entry_id, "type": msg_type, "content": tc.text})
}
MessageKind::AssistantResponse(ar) => {
let thoughts: Vec<Value> = ar
.thinking
.iter()
.map(|t| json!({"subject": "", "description": t}))
.collect();
let tool_calls: Vec<Value> = ar
.tool_calls
.iter()
.map(|tc| json!({"name": tc.name, "args": tc.arguments}))
.collect();
json!({
"id": msg.entry_id,
"type": "gemini",
"content": ar.text,
"thoughts": thoughts,
"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": format!("$ {}\n{}", bo.command, bo.output)
}),
})
.collect();
serde_json::to_string_pretty(&json!({"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_push_lines(msg: &ConversationMessage, lines: &mut Vec<String>) {
match &msg.kind {
MessageKind::TextContent(tc) => {
lines.push(
serde_json::to_string(&json!({
"type": "response_item",
"payload": {
"type": "message",
"id": msg.entry_id,
"role": tc.role,
"content": [{"type": "text", "text": tc.text}]
}
}))
.expect("serialize codex message"),
);
}
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!({"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(
serde_json::to_string(&json!({
"type": "response_item",
"payload": {
"type": "message",
"id": id,
"role": "assistant",
"content": [{"type": "text", "text": ar.text}]
}
}))
.expect("serialize codex assistant message"),
);
}
for tc in &ar.tool_calls {
let id = codex_sub_id(&msg.entry_id, sub);
sub += 1;
lines.push(
serde_json::to_string(&json!({
"type": "response_item",
"payload": {
"type": "function_call",
"id": id,
"name": tc.name,
"arguments": tc.arguments
}
}))
.expect("serialize codex function_call"),
);
}
}
MessageKind::ToolResultData(tr) => {
lines.push(
serde_json::to_string(&json!({
"type": "response_item",
"payload": {
"type": "function_call_output",
"id": msg.entry_id,
"call_id": tr.tool_name,
"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",
"id": msg.entry_id,
"call_id": "bash",
"output": format!("$ {}\n{}", bo.command, bo.output)
}
}))
.expect("serialize codex bash output"),
);
}
}
}
fn render_codex(messages: &[ConversationMessage], context_id: &str) -> String {
let mut lines = vec![
serde_json::to_string(&json!({"type": "session_meta", "payload": {"id": context_id}}))
.expect("serialize codex header"),
];
for msg in messages {
codex_push_lines(msg, &mut lines);
}
to_jsonl(&lines)
}