Skip to content

Commit b10b40f

Browse files
sangggggclaude
andauthored
feat(parser): split Gemini CLI thoughts into separate thinking messages (#103)
* feat: add SlashCommand message type for Claude Code command blocks Add support for parsing and displaying XML command blocks from Claude Code JSONL files (e.g., /clear, /help, /model commands). Changes: - Add SlashCommand variant to MessageType enum - Add SlashCommandData struct with command_name, message, args, stdout fields - Add regex parsing in claude_code.rs for XML command blocks - Add database migration 017 for slash_command message type - Add yellow/amber styling in TUI for slash command messages - Add SlashCommandMessage component in React GUI - Add unit tests for slash command parsing - Fix clippy large_enum_variant warning by boxing MessageGroup fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(parser): split Gemini CLI thoughts into separate thinking messages Gemini CLI data compacts thinking, tool use, and AI message content into a single message object with a `thoughts[]` array. This differs from our schema which expects thinking to be separate messages with `MessageType::Thinking`. Changes: - Modified `convert_session_message` to return `Vec<Message>` instead of single `Message` - Create separate `MessageType::Thinking` messages from each thought in the `thoughts[]` array before the main message - Format thinking content as `**subject**\n\ndescription` - Use thought's timestamp with fallback to main message timestamp - Only process thoughts for Assistant messages (ignore on User) - Use index for thinking message UUID to avoid collision - Updated callers to handle multiple messages with proper sequence numbering 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5bda79d commit b10b40f

File tree

1 file changed

+297
-18
lines changed

1 file changed

+297
-18
lines changed

crates/retrochat-core/src/parsers/gemini_cli.rs

Lines changed: 297 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::io::Read;
77
use std::path::Path;
88
use uuid::Uuid;
99

10+
use crate::models::message::MessageType;
1011
use crate::models::{ChatSession, Message, MessageRole, ToolResult, ToolUse};
1112
use crate::models::{Provider, SessionState};
1213
use crate::parsers::project_inference::ProjectInference;
@@ -279,15 +280,19 @@ impl GeminiCLIParser {
279280
// Convert messages
280281
let mut converted_messages = Vec::new();
281282
let mut total_tokens = 0u32;
283+
let mut sequence = 1usize;
282284

283-
for (index, session_message) in messages.iter().enumerate() {
284-
let message = self.convert_session_message(session_message, session_id, index + 1)?;
285+
for session_message in messages.iter() {
286+
let new_messages =
287+
self.convert_session_message(session_message, session_id, sequence)?;
285288

286-
if let Some(token_count) = message.token_count {
287-
total_tokens += token_count;
289+
for message in new_messages {
290+
if let Some(token_count) = message.token_count {
291+
total_tokens += token_count;
292+
}
293+
sequence += 1;
294+
converted_messages.push(message);
288295
}
289-
290-
converted_messages.push(message);
291296
}
292297

293298
chat_session.message_count = converted_messages.len() as u32;
@@ -342,15 +347,19 @@ impl GeminiCLIParser {
342347

343348
let mut messages = Vec::new();
344349
let mut total_tokens = 0u32;
350+
let mut sequence = 1usize;
345351

346-
for (index, session_message) in session.messages.iter().enumerate() {
347-
let message = self.convert_session_message(session_message, session_id, index + 1)?;
352+
for session_message in session.messages.iter() {
353+
let new_messages =
354+
self.convert_session_message(session_message, session_id, sequence)?;
348355

349-
if let Some(token_count) = message.token_count {
350-
total_tokens += token_count;
356+
for message in new_messages {
357+
if let Some(token_count) = message.token_count {
358+
total_tokens += token_count;
359+
}
360+
sequence += 1;
361+
messages.push(message);
351362
}
352-
353-
messages.push(message);
354363
}
355364

356365
chat_session.message_count = messages.len() as u32;
@@ -555,12 +564,14 @@ impl GeminiCLIParser {
555564
(tool_uses, tool_results)
556565
}
557566

567+
/// Convert a Gemini session message to one or more Messages.
568+
/// Returns multiple messages when thoughts are present (thinking messages + main message).
558569
fn convert_session_message(
559570
&self,
560571
session_message: &GeminiSessionMessage,
561572
session_id: Uuid,
562-
sequence: usize,
563-
) -> Result<Message> {
573+
start_sequence: usize,
574+
) -> Result<Vec<Message>> {
564575
let role = match session_message.message_type.as_str() {
565576
"user" => MessageRole::User,
566577
"gemini" => MessageRole::Assistant,
@@ -579,7 +590,49 @@ impl GeminiCLIParser {
579590

580591
let timestamp = self.parse_timestamp(&session_message.timestamp)?;
581592

582-
// Generate a deterministic UUID for the message
593+
let mut messages = Vec::new();
594+
let mut current_sequence = start_sequence;
595+
596+
// Create separate thinking messages for each thought (only for assistant messages)
597+
if role == MessageRole::Assistant {
598+
if let Some(thoughts) = &session_message.thoughts {
599+
for (idx, thought) in thoughts.iter().enumerate() {
600+
let thinking_content =
601+
format!("**{}**\n\n{}", thought.subject, thought.description);
602+
let thinking_timestamp = self
603+
.parse_timestamp(&thought.timestamp)
604+
.unwrap_or(timestamp);
605+
606+
// Generate deterministic UUID for thinking message using index to avoid collision
607+
let thinking_id = self.generate_uuid_from_string(&format!(
608+
"{session_id}-thought-{}-{}",
609+
session_message.id, idx
610+
));
611+
612+
let mut thinking_message = Message::new(
613+
session_id,
614+
MessageRole::Assistant,
615+
thinking_content,
616+
thinking_timestamp,
617+
current_sequence as u32,
618+
)
619+
.with_message_type(MessageType::Thinking);
620+
621+
thinking_message.id = thinking_id;
622+
623+
// Token estimation for thinking content
624+
let thinking_tokens = (thinking_message.content.len() / 4) as u32;
625+
if thinking_tokens > 0 {
626+
thinking_message = thinking_message.with_token_count(thinking_tokens);
627+
}
628+
629+
messages.push(thinking_message);
630+
current_sequence += 1;
631+
}
632+
}
633+
}
634+
635+
// Generate a deterministic UUID for the main message
583636
let message_id = if let Ok(uuid) = Uuid::parse_str(&session_message.id) {
584637
uuid
585638
} else {
@@ -591,7 +644,7 @@ impl GeminiCLIParser {
591644
role,
592645
session_message.content.clone(),
593646
timestamp,
594-
sequence as u32,
647+
current_sequence as u32,
595648
);
596649

597650
message.id = message_id;
@@ -608,13 +661,15 @@ impl GeminiCLIParser {
608661
message = message.with_token_count(tokens.total);
609662
} else {
610663
// Estimate token count based on content length
611-
let estimated_tokens = (message.content.len() / 4) as u32; // Rough estimate: 4 chars per token
664+
let estimated_tokens = (message.content.len() / 4) as u32;
612665
if estimated_tokens > 0 {
613666
message = message.with_token_count(estimated_tokens);
614667
}
615668
}
616669

617-
Ok(message)
670+
messages.push(message);
671+
672+
Ok(messages)
618673
}
619674

620675
async fn convert_conversation(
@@ -1027,4 +1082,228 @@ mod tests {
10271082

10281083
assert_eq!(count, 2);
10291084
}
1085+
1086+
#[tokio::test]
1087+
async fn test_parse_gemini_session_with_thoughts() {
1088+
use std::fs;
1089+
use tempfile::TempDir;
1090+
1091+
let temp_dir = TempDir::new().unwrap();
1092+
let file_path = temp_dir.path().join("session-test-thoughts.json");
1093+
1094+
let sample_data = r#"{
1095+
"sessionId": "test-session-123",
1096+
"projectHash": "abc123",
1097+
"startTime": "2024-01-01T10:00:00Z",
1098+
"lastUpdated": "2024-01-01T10:05:00Z",
1099+
"messages": [
1100+
{
1101+
"id": "msg-1",
1102+
"timestamp": "2024-01-01T10:00:00Z",
1103+
"type": "user",
1104+
"content": "Hello"
1105+
},
1106+
{
1107+
"id": "msg-2",
1108+
"timestamp": "2024-01-01T10:01:00Z",
1109+
"type": "gemini",
1110+
"content": "Hi there!",
1111+
"thoughts": [
1112+
{
1113+
"subject": "Analyzing Request",
1114+
"description": "I'm analyzing the user's greeting.",
1115+
"timestamp": "2024-01-01T10:00:30Z"
1116+
},
1117+
{
1118+
"subject": "Formulating Response",
1119+
"description": "I'll respond with a friendly greeting.",
1120+
"timestamp": "2024-01-01T10:00:45Z"
1121+
}
1122+
]
1123+
}
1124+
]
1125+
}"#;
1126+
1127+
fs::write(&file_path, sample_data).unwrap();
1128+
1129+
let parser = GeminiCLIParser::new(&file_path);
1130+
let result = parser.parse().await;
1131+
1132+
assert!(result.is_ok());
1133+
let sessions = result.unwrap();
1134+
assert_eq!(sessions.len(), 1);
1135+
1136+
let (session, messages) = &sessions[0];
1137+
1138+
// Should have 4 messages: 1 user + 2 thinking + 1 main assistant
1139+
assert_eq!(messages.len(), 4);
1140+
assert_eq!(session.message_count, 4);
1141+
1142+
// First message: user
1143+
assert_eq!(messages[0].role, MessageRole::User);
1144+
assert_eq!(messages[0].content, "Hello");
1145+
assert_eq!(messages[0].sequence_number, 1);
1146+
1147+
// Second message: thinking 1
1148+
assert_eq!(messages[1].role, MessageRole::Assistant);
1149+
assert_eq!(messages[1].message_type, MessageType::Thinking);
1150+
assert!(messages[1].content.contains("**Analyzing Request**"));
1151+
assert!(messages[1]
1152+
.content
1153+
.contains("analyzing the user's greeting"));
1154+
assert_eq!(messages[1].sequence_number, 2);
1155+
1156+
// Third message: thinking 2
1157+
assert_eq!(messages[2].role, MessageRole::Assistant);
1158+
assert_eq!(messages[2].message_type, MessageType::Thinking);
1159+
assert!(messages[2].content.contains("**Formulating Response**"));
1160+
assert_eq!(messages[2].sequence_number, 3);
1161+
1162+
// Fourth message: main assistant response
1163+
assert_eq!(messages[3].role, MessageRole::Assistant);
1164+
assert_eq!(messages[3].message_type, MessageType::SimpleMessage);
1165+
assert_eq!(messages[3].content, "Hi there!");
1166+
assert_eq!(messages[3].sequence_number, 4);
1167+
}
1168+
1169+
#[tokio::test]
1170+
async fn test_parse_gemini_session_no_thoughts() {
1171+
use std::fs;
1172+
use tempfile::TempDir;
1173+
1174+
let temp_dir = TempDir::new().unwrap();
1175+
let file_path = temp_dir.path().join("session-no-thoughts.json");
1176+
1177+
let sample_data = r#"{
1178+
"sessionId": "test-session-456",
1179+
"projectHash": "def456",
1180+
"startTime": "2024-01-01T10:00:00Z",
1181+
"lastUpdated": "2024-01-01T10:02:00Z",
1182+
"messages": [
1183+
{
1184+
"id": "msg-1",
1185+
"timestamp": "2024-01-01T10:00:00Z",
1186+
"type": "user",
1187+
"content": "Hello"
1188+
},
1189+
{
1190+
"id": "msg-2",
1191+
"timestamp": "2024-01-01T10:01:00Z",
1192+
"type": "gemini",
1193+
"content": "Hi there!"
1194+
}
1195+
]
1196+
}"#;
1197+
1198+
fs::write(&file_path, sample_data).unwrap();
1199+
1200+
let parser = GeminiCLIParser::new(&file_path);
1201+
let result = parser.parse().await;
1202+
1203+
assert!(result.is_ok());
1204+
let sessions = result.unwrap();
1205+
let (session, messages) = &sessions[0];
1206+
1207+
// Should have 2 messages: 1 user + 1 assistant (no thinking)
1208+
assert_eq!(messages.len(), 2);
1209+
assert_eq!(session.message_count, 2);
1210+
1211+
assert_eq!(messages[0].role, MessageRole::User);
1212+
assert_eq!(messages[1].role, MessageRole::Assistant);
1213+
assert_eq!(messages[1].message_type, MessageType::SimpleMessage);
1214+
}
1215+
1216+
#[tokio::test]
1217+
async fn test_parse_gemini_session_user_message_ignores_thoughts() {
1218+
use std::fs;
1219+
use tempfile::TempDir;
1220+
1221+
let temp_dir = TempDir::new().unwrap();
1222+
let file_path = temp_dir.path().join("session-user-thoughts.json");
1223+
1224+
// Even if user messages somehow have thoughts, they should be ignored
1225+
let sample_data = r#"{
1226+
"sessionId": "test-session-789",
1227+
"projectHash": "ghi789",
1228+
"startTime": "2024-01-01T10:00:00Z",
1229+
"lastUpdated": "2024-01-01T10:01:00Z",
1230+
"messages": [
1231+
{
1232+
"id": "msg-1",
1233+
"timestamp": "2024-01-01T10:00:00Z",
1234+
"type": "user",
1235+
"content": "Hello",
1236+
"thoughts": [
1237+
{
1238+
"subject": "Should Be Ignored",
1239+
"description": "This thought should not create a message.",
1240+
"timestamp": "2024-01-01T10:00:00Z"
1241+
}
1242+
]
1243+
}
1244+
]
1245+
}"#;
1246+
1247+
fs::write(&file_path, sample_data).unwrap();
1248+
1249+
let parser = GeminiCLIParser::new(&file_path);
1250+
let result = parser.parse().await;
1251+
1252+
assert!(result.is_ok());
1253+
let sessions = result.unwrap();
1254+
let (session, messages) = &sessions[0];
1255+
1256+
// Should have only 1 message (user thoughts are ignored)
1257+
assert_eq!(messages.len(), 1);
1258+
assert_eq!(session.message_count, 1);
1259+
assert_eq!(messages[0].role, MessageRole::User);
1260+
}
1261+
1262+
#[tokio::test]
1263+
async fn test_parse_gemini_session_empty_thoughts_array() {
1264+
use std::fs;
1265+
use tempfile::TempDir;
1266+
1267+
let temp_dir = TempDir::new().unwrap();
1268+
let file_path = temp_dir.path().join("session-empty-thoughts.json");
1269+
1270+
// Empty thoughts array should not create any thinking messages
1271+
let sample_data = r#"{
1272+
"sessionId": "test-session-empty",
1273+
"projectHash": "empty123",
1274+
"startTime": "2024-01-01T10:00:00Z",
1275+
"lastUpdated": "2024-01-01T10:01:00Z",
1276+
"messages": [
1277+
{
1278+
"id": "msg-1",
1279+
"timestamp": "2024-01-01T10:00:00Z",
1280+
"type": "user",
1281+
"content": "Hello"
1282+
},
1283+
{
1284+
"id": "msg-2",
1285+
"timestamp": "2024-01-01T10:01:00Z",
1286+
"type": "gemini",
1287+
"content": "Hi there!",
1288+
"thoughts": []
1289+
}
1290+
]
1291+
}"#;
1292+
1293+
fs::write(&file_path, sample_data).unwrap();
1294+
1295+
let parser = GeminiCLIParser::new(&file_path);
1296+
let result = parser.parse().await;
1297+
1298+
assert!(result.is_ok());
1299+
let sessions = result.unwrap();
1300+
let (session, messages) = &sessions[0];
1301+
1302+
// Should have only 2 messages (no thinking for empty array)
1303+
assert_eq!(messages.len(), 2);
1304+
assert_eq!(session.message_count, 2);
1305+
assert_eq!(messages[0].role, MessageRole::User);
1306+
assert_eq!(messages[1].role, MessageRole::Assistant);
1307+
assert_eq!(messages[1].message_type, MessageType::SimpleMessage);
1308+
}
10301309
}

0 commit comments

Comments
 (0)