From 8211ace7355cd907e6c1912016b4524ffb3b83a3 Mon Sep 17 00:00:00 2001 From: sanggggg Date: Fri, 26 Dec 2025 13:47:28 +0900 Subject: [PATCH 1/2] feat: add SlashCommand message type for Claude Code command blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...c3d305c7696877b7d1126269dadc2166ff712.json | 20 ++ ...147d541ee8f69506fbc36386ea34a5c9f6da8.json | 12 ++ ...0aeac9b75704263ada069cb87c6faa6cb4a7d.json | 12 ++ ...e6f81107d887d43ca950bd85c7a7081cd96fa.json | 20 ++ ...1cb716bc1f40e8035eec1289702a2287e574a.json | 68 ++++++ ...28dac9d6f71b720b2488ab112584e0ee19a03.json | 20 ++ ...732b85a27cf0f1453e86ede0e179608f5830c.json | 62 ++++++ ...d4357ba7381aafb7b0efc8aefd8727ac7e786.json | 62 ++++++ ...8f5278519ddb5f70b25b2a0c48231add2bace.json | 62 ++++++ ...cb87bd624a286ee2e4efdd2932937ea3ce63f.json | 62 ++++++ ...da353b93e6c0002f1ea5eaec6937fc93486ad.json | 62 ++++++ ...b14c366b42454be105f69933e49287d2728db.json | 62 ++++++ ...fb499f84b7a13b2eb745c468e38a8f35ec58c.json | 62 ++++++ ...ebb2209038b90dafd66a4e5d59b5b7c3b4746.json | 12 ++ ...d7779bf47c7d24a2150b8cdcf025f15482b98.json | 12 ++ ...0b0815935788644dc0ef54ea7f872cd3d73ce.json | 68 ++++++ .../017_add_slash_command_message_type.sql | 69 ++++++ crates/retrochat-core/src/models/message.rs | 21 ++ .../retrochat-core/src/parsers/claude_code.rs | 201 +++++++++++++++++- .../src/services/analytics/data_collector.rs | 1 + .../src/services/query_service.rs | 12 +- crates/retrochat-tui/src/session_detail.rs | 14 +- ui-react/src/components/session-detail.tsx | 66 +++++- 23 files changed, 1043 insertions(+), 19 deletions(-) create mode 100644 .sqlx/query-16d83cb7e55768d51a99ae240dac3d305c7696877b7d1126269dadc2166ff712.json create mode 100644 .sqlx/query-1f8ef75ee4ce329f878ad852323147d541ee8f69506fbc36386ea34a5c9f6da8.json create mode 100644 .sqlx/query-52ec7d328d2f43ace95296caa380aeac9b75704263ada069cb87c6faa6cb4a7d.json create mode 100644 .sqlx/query-70b55d0a9faf7bea3ba229c92f5e6f81107d887d43ca950bd85c7a7081cd96fa.json create mode 100644 .sqlx/query-76fa1a7b667a1c5a8e5cbb355261cb716bc1f40e8035eec1289702a2287e574a.json create mode 100644 .sqlx/query-85e79f8bee1545849066899470728dac9d6f71b720b2488ab112584e0ee19a03.json create mode 100644 .sqlx/query-a3a2bf6d2877a712b961a3dfc1d732b85a27cf0f1453e86ede0e179608f5830c.json create mode 100644 .sqlx/query-a60c6fbf087d65237d635258999d4357ba7381aafb7b0efc8aefd8727ac7e786.json create mode 100644 .sqlx/query-abf269f1fdf77d92aeb10e953fa8f5278519ddb5f70b25b2a0c48231add2bace.json create mode 100644 .sqlx/query-b090411c4cb1bd288c5f024dbb7cb87bd624a286ee2e4efdd2932937ea3ce63f.json create mode 100644 .sqlx/query-b9301b4ad8601f33efc34fa29d2da353b93e6c0002f1ea5eaec6937fc93486ad.json create mode 100644 .sqlx/query-cc2b8d306223e34b8105cf775d9b14c366b42454be105f69933e49287d2728db.json create mode 100644 .sqlx/query-e09e456b7afa3cf68557e2daa8cfb499f84b7a13b2eb745c468e38a8f35ec58c.json create mode 100644 .sqlx/query-f64b389d270e02d0d2111c50691ebb2209038b90dafd66a4e5d59b5b7c3b4746.json create mode 100644 .sqlx/query-fa1149b5d14759d111418649428d7779bf47c7d24a2150b8cdcf025f15482b98.json create mode 100644 .sqlx/query-fc4e5e50bba17e73854cd3e68d50b0815935788644dc0ef54ea7f872cd3d73ce.json create mode 100644 crates/retrochat-core/migrations/017_add_slash_command_message_type.sql diff --git a/.sqlx/query-16d83cb7e55768d51a99ae240dac3d305c7696877b7d1126269dadc2166ff712.json b/.sqlx/query-16d83cb7e55768d51a99ae240dac3d305c7696877b7d1126269dadc2166ff712.json new file mode 100644 index 0000000..2f69ae9 --- /dev/null +++ b/.sqlx/query-16d83cb7e55768d51a99ae240dac3d305c7696877b7d1126269dadc2166ff712.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT session_id FROM analytics_requests WHERE id = ?", + "describe": { + "columns": [ + { + "name": "session_id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "16d83cb7e55768d51a99ae240dac3d305c7696877b7d1126269dadc2166ff712" +} diff --git a/.sqlx/query-1f8ef75ee4ce329f878ad852323147d541ee8f69506fbc36386ea34a5c9f6da8.json b/.sqlx/query-1f8ef75ee4ce329f878ad852323147d541ee8f69506fbc36386ea34a5c9f6da8.json new file mode 100644 index 0000000..88d0e31 --- /dev/null +++ b/.sqlx/query-1f8ef75ee4ce329f878ad852323147d541ee8f69506fbc36386ea34a5c9f6da8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO analytics (\n id, analytics_request_id, session_id, generated_at,\n qualitative_output_json,\n ai_quantitative_output_json,\n metric_quantitative_output_json,\n model_used, analysis_duration_ms\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "1f8ef75ee4ce329f878ad852323147d541ee8f69506fbc36386ea34a5c9f6da8" +} diff --git a/.sqlx/query-52ec7d328d2f43ace95296caa380aeac9b75704263ada069cb87c6faa6cb4a7d.json b/.sqlx/query-52ec7d328d2f43ace95296caa380aeac9b75704263ada069cb87c6faa6cb4a7d.json new file mode 100644 index 0000000..fca33d6 --- /dev/null +++ b/.sqlx/query-52ec7d328d2f43ace95296caa380aeac9b75704263ada069cb87c6faa6cb4a7d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO analytics_requests (\n id, session_id, status, started_at, completed_at,\n created_by, error_message, custom_prompt\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "52ec7d328d2f43ace95296caa380aeac9b75704263ada069cb87c6faa6cb4a7d" +} diff --git a/.sqlx/query-70b55d0a9faf7bea3ba229c92f5e6f81107d887d43ca950bd85c7a7081cd96fa.json b/.sqlx/query-70b55d0a9faf7bea3ba229c92f5e6f81107d887d43ca950bd85c7a7081cd96fa.json new file mode 100644 index 0000000..927d6da --- /dev/null +++ b/.sqlx/query-70b55d0a9faf7bea3ba229c92f5e6f81107d887d43ca950bd85c7a7081cd96fa.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) as count FROM analytics_requests WHERE status = ?", + "describe": { + "columns": [ + { + "name": "count", + "ordinal": 0, + "type_info": "Int" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "70b55d0a9faf7bea3ba229c92f5e6f81107d887d43ca950bd85c7a7081cd96fa" +} diff --git a/.sqlx/query-76fa1a7b667a1c5a8e5cbb355261cb716bc1f40e8035eec1289702a2287e574a.json b/.sqlx/query-76fa1a7b667a1c5a8e5cbb355261cb716bc1f40e8035eec1289702a2287e574a.json new file mode 100644 index 0000000..a8ed88b --- /dev/null +++ b/.sqlx/query-76fa1a7b667a1c5a8e5cbb355261cb716bc1f40e8035eec1289702a2287e574a.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id, analytics_request_id, session_id, generated_at,\n qualitative_output_json,\n ai_quantitative_output_json,\n metric_quantitative_output_json,\n model_used, analysis_duration_ms\n FROM analytics\n WHERE analytics_request_id = ?\n ORDER BY generated_at DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "analytics_request_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "generated_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "qualitative_output_json", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "ai_quantitative_output_json", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "metric_quantitative_output_json", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "model_used", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "analysis_duration_ms", + "ordinal": 8, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + true, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "76fa1a7b667a1c5a8e5cbb355261cb716bc1f40e8035eec1289702a2287e574a" +} diff --git a/.sqlx/query-85e79f8bee1545849066899470728dac9d6f71b720b2488ab112584e0ee19a03.json b/.sqlx/query-85e79f8bee1545849066899470728dac9d6f71b720b2488ab112584e0ee19a03.json new file mode 100644 index 0000000..26e5990 --- /dev/null +++ b/.sqlx/query-85e79f8bee1545849066899470728dac9d6f71b720b2488ab112584e0ee19a03.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) as count FROM analytics_requests WHERE status IN ('pending', 'running')", + "describe": { + "columns": [ + { + "name": "count", + "ordinal": 0, + "type_info": "Int" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + null + ] + }, + "hash": "85e79f8bee1545849066899470728dac9d6f71b720b2488ab112584e0ee19a03" +} diff --git a/.sqlx/query-a3a2bf6d2877a712b961a3dfc1d732b85a27cf0f1453e86ede0e179608f5830c.json b/.sqlx/query-a3a2bf6d2877a712b961a3dfc1d732b85a27cf0f1453e86ede0e179608f5830c.json new file mode 100644 index 0000000..e838eba --- /dev/null +++ b/.sqlx/query-a3a2bf6d2877a712b961a3dfc1d732b85a27cf0f1453e86ede0e179608f5830c.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests WHERE status = ? ORDER BY started_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "a3a2bf6d2877a712b961a3dfc1d732b85a27cf0f1453e86ede0e179608f5830c" +} diff --git a/.sqlx/query-a60c6fbf087d65237d635258999d4357ba7381aafb7b0efc8aefd8727ac7e786.json b/.sqlx/query-a60c6fbf087d65237d635258999d4357ba7381aafb7b0efc8aefd8727ac7e786.json new file mode 100644 index 0000000..86e1da5 --- /dev/null +++ b/.sqlx/query-a60c6fbf087d65237d635258999d4357ba7381aafb7b0efc8aefd8727ac7e786.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests WHERE started_at >= ? ORDER BY started_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "a60c6fbf087d65237d635258999d4357ba7381aafb7b0efc8aefd8727ac7e786" +} diff --git a/.sqlx/query-abf269f1fdf77d92aeb10e953fa8f5278519ddb5f70b25b2a0c48231add2bace.json b/.sqlx/query-abf269f1fdf77d92aeb10e953fa8f5278519ddb5f70b25b2a0c48231add2bace.json new file mode 100644 index 0000000..d1d6421 --- /dev/null +++ b/.sqlx/query-abf269f1fdf77d92aeb10e953fa8f5278519ddb5f70b25b2a0c48231add2bace.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests ORDER BY started_at DESC LIMIT ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "abf269f1fdf77d92aeb10e953fa8f5278519ddb5f70b25b2a0c48231add2bace" +} diff --git a/.sqlx/query-b090411c4cb1bd288c5f024dbb7cb87bd624a286ee2e4efdd2932937ea3ce63f.json b/.sqlx/query-b090411c4cb1bd288c5f024dbb7cb87bd624a286ee2e4efdd2932937ea3ce63f.json new file mode 100644 index 0000000..cbd27ff --- /dev/null +++ b/.sqlx/query-b090411c4cb1bd288c5f024dbb7cb87bd624a286ee2e4efdd2932937ea3ce63f.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "b090411c4cb1bd288c5f024dbb7cb87bd624a286ee2e4efdd2932937ea3ce63f" +} diff --git a/.sqlx/query-b9301b4ad8601f33efc34fa29d2da353b93e6c0002f1ea5eaec6937fc93486ad.json b/.sqlx/query-b9301b4ad8601f33efc34fa29d2da353b93e6c0002f1ea5eaec6937fc93486ad.json new file mode 100644 index 0000000..139a72e --- /dev/null +++ b/.sqlx/query-b9301b4ad8601f33efc34fa29d2da353b93e6c0002f1ea5eaec6937fc93486ad.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests WHERE created_by = ? ORDER BY started_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "b9301b4ad8601f33efc34fa29d2da353b93e6c0002f1ea5eaec6937fc93486ad" +} diff --git a/.sqlx/query-cc2b8d306223e34b8105cf775d9b14c366b42454be105f69933e49287d2728db.json b/.sqlx/query-cc2b8d306223e34b8105cf775d9b14c366b42454be105f69933e49287d2728db.json new file mode 100644 index 0000000..43f052d --- /dev/null +++ b/.sqlx/query-cc2b8d306223e34b8105cf775d9b14c366b42454be105f69933e49287d2728db.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests WHERE status IN ('pending', 'running') ORDER BY started_at ASC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "cc2b8d306223e34b8105cf775d9b14c366b42454be105f69933e49287d2728db" +} diff --git a/.sqlx/query-e09e456b7afa3cf68557e2daa8cfb499f84b7a13b2eb745c468e38a8f35ec58c.json b/.sqlx/query-e09e456b7afa3cf68557e2daa8cfb499f84b7a13b2eb745c468e38a8f35ec58c.json new file mode 100644 index 0000000..4b3205c --- /dev/null +++ b/.sqlx/query-e09e456b7afa3cf68557e2daa8cfb499f84b7a13b2eb745c468e38a8f35ec58c.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM analytics_requests WHERE session_id = ? ORDER BY started_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "status", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "started_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "completed_at", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_by", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "error_message", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "custom_prompt", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "e09e456b7afa3cf68557e2daa8cfb499f84b7a13b2eb745c468e38a8f35ec58c" +} diff --git a/.sqlx/query-f64b389d270e02d0d2111c50691ebb2209038b90dafd66a4e5d59b5b7c3b4746.json b/.sqlx/query-f64b389d270e02d0d2111c50691ebb2209038b90dafd66a4e5d59b5b7c3b4746.json new file mode 100644 index 0000000..263fa05 --- /dev/null +++ b/.sqlx/query-f64b389d270e02d0d2111c50691ebb2209038b90dafd66a4e5d59b5b7c3b4746.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM analytics_requests WHERE completed_at IS NOT NULL AND completed_at < ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "f64b389d270e02d0d2111c50691ebb2209038b90dafd66a4e5d59b5b7c3b4746" +} diff --git a/.sqlx/query-fa1149b5d14759d111418649428d7779bf47c7d24a2150b8cdcf025f15482b98.json b/.sqlx/query-fa1149b5d14759d111418649428d7779bf47c7d24a2150b8cdcf025f15482b98.json new file mode 100644 index 0000000..106af1f --- /dev/null +++ b/.sqlx/query-fa1149b5d14759d111418649428d7779bf47c7d24a2150b8cdcf025f15482b98.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM analytics_requests WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "fa1149b5d14759d111418649428d7779bf47c7d24a2150b8cdcf025f15482b98" +} diff --git a/.sqlx/query-fc4e5e50bba17e73854cd3e68d50b0815935788644dc0ef54ea7f872cd3d73ce.json b/.sqlx/query-fc4e5e50bba17e73854cd3e68d50b0815935788644dc0ef54ea7f872cd3d73ce.json new file mode 100644 index 0000000..cdadcdb --- /dev/null +++ b/.sqlx/query-fc4e5e50bba17e73854cd3e68d50b0815935788644dc0ef54ea7f872cd3d73ce.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id, analytics_request_id, session_id, generated_at,\n qualitative_output_json,\n ai_quantitative_output_json,\n metric_quantitative_output_json,\n model_used, analysis_duration_ms\n FROM analytics\n WHERE id = ?\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "analytics_request_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "session_id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "generated_at", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "qualitative_output_json", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "ai_quantitative_output_json", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "metric_quantitative_output_json", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "model_used", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "analysis_duration_ms", + "ordinal": 8, + "type_info": "Int64" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + true, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "fc4e5e50bba17e73854cd3e68d50b0815935788644dc0ef54ea7f872cd3d73ce" +} diff --git a/crates/retrochat-core/migrations/017_add_slash_command_message_type.sql b/crates/retrochat-core/migrations/017_add_slash_command_message_type.sql new file mode 100644 index 0000000..d41340b --- /dev/null +++ b/crates/retrochat-core/migrations/017_add_slash_command_message_type.sql @@ -0,0 +1,69 @@ +-- Add slash_command message type +-- Migration: 017_add_slash_command_message_type +-- Description: Add 'slash_command' to message_type CHECK constraint to support Claude Code slash command messages + +-- Drop the old constraint and add the new one with 'slash_command' +-- SQLite doesn't support ALTER TABLE ... DROP CONSTRAINT, so we need to recreate the table + +-- Create a temporary table with the new schema +CREATE TABLE messages_new ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('User', 'Assistant', 'System')), + content TEXT NOT NULL CHECK (length(content) > 0), + timestamp TEXT NOT NULL, + token_count INTEGER CHECK (token_count >= 0), + metadata TEXT, -- JSON object + sequence_number INTEGER NOT NULL, + message_type TEXT NOT NULL DEFAULT 'simple_message' CHECK (message_type IN ('tool_request', 'tool_result', 'thinking', 'slash_command', 'simple_message')), + tool_operation_id TEXT, + FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (tool_operation_id) REFERENCES tool_operations(id) ON DELETE SET NULL, + UNIQUE(session_id, sequence_number) +); + +-- Copy data from old table +INSERT INTO messages_new (id, session_id, role, content, timestamp, token_count, metadata, sequence_number, message_type, tool_operation_id) +SELECT id, session_id, role, content, timestamp, token_count, metadata, sequence_number, message_type, tool_operation_id +FROM messages; + +-- Drop old table +DROP TABLE messages; + +-- Rename new table to messages +ALTER TABLE messages_new RENAME TO messages; + +-- Recreate indexes +CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id); +CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role); +CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp); +CREATE INDEX IF NOT EXISTS idx_messages_sequence ON messages(session_id, sequence_number); +CREATE INDEX IF NOT EXISTS idx_messages_message_type ON messages(message_type); +CREATE INDEX IF NOT EXISTS idx_messages_tool_operation ON messages(tool_operation_id); + +-- Rebuild FTS index after table recreation +-- First, delete and recreate the FTS table +DROP TABLE IF EXISTS messages_fts; + +CREATE VIRTUAL TABLE messages_fts USING fts5( + content, + session_id UNINDEXED, + role UNINDEXED, + timestamp UNINDEXED, + content='messages', + content_rowid='rowid' +); + +-- Recreate FTS triggers +CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, content) VALUES (NEW.rowid, NEW.content); +END; + +CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', OLD.rowid, OLD.content); +END; + +CREATE TRIGGER messages_fts_update AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', OLD.rowid, OLD.content); + INSERT INTO messages_fts(rowid, content) VALUES (NEW.rowid, NEW.content); +END; diff --git a/crates/retrochat-core/src/models/message.rs b/crates/retrochat-core/src/models/message.rs index ac8e8b2..f5866fc 100644 --- a/crates/retrochat-core/src/models/message.rs +++ b/crates/retrochat-core/src/models/message.rs @@ -15,10 +15,24 @@ pub enum MessageType { ToolRequest, ToolResult, Thinking, + SlashCommand, #[default] SimpleMessage, } +/// Parsed slash command from XML blocks (e.g., /clear, /help) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlashCommandData { + /// The command name (e.g., "/clear", "/help") + pub command_name: String, + /// The command message/description + pub command_message: Option, + /// Command arguments if any + pub command_args: Option, + /// Command stdout result + pub stdout: Option, +} + impl std::fmt::Display for MessageRole { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -48,6 +62,7 @@ impl std::fmt::Display for MessageType { MessageType::ToolRequest => write!(f, "tool_request"), MessageType::ToolResult => write!(f, "tool_result"), MessageType::Thinking => write!(f, "thinking"), + MessageType::SlashCommand => write!(f, "slash_command"), MessageType::SimpleMessage => write!(f, "simple_message"), } } @@ -61,6 +76,7 @@ impl std::str::FromStr for MessageType { "tool_request" => Ok(MessageType::ToolRequest), "tool_result" => Ok(MessageType::ToolResult), "thinking" => Ok(MessageType::Thinking), + "slash_command" => Ok(MessageType::SlashCommand), "simple_message" => Ok(MessageType::SimpleMessage), _ => Err(format!("Unknown message type: {s}")), } @@ -219,6 +235,11 @@ impl Message { matches!(self.message_type, MessageType::Thinking) } + /// Check if this message is a slash command + pub fn is_slash_command(&self) -> bool { + matches!(self.message_type, MessageType::SlashCommand) + } + /// Check if this message has an associated tool operation pub fn has_tool_operation(&self) -> bool { self.tool_operation_id.is_some() diff --git a/crates/retrochat-core/src/parsers/claude_code.rs b/crates/retrochat-core/src/parsers/claude_code.rs index 416c08c..9b9dbf6 100644 --- a/crates/retrochat-core/src/parsers/claude_code.rs +++ b/crates/retrochat-core/src/parsers/claude_code.rs @@ -1,5 +1,7 @@ use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Utc}; +use lazy_static::lazy_static; +use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fs::File; @@ -7,12 +9,24 @@ use std::io::{BufRead, BufReader, Lines}; use std::path::Path; use uuid::Uuid; -use crate::models::message::{MessageType, ToolResult, ToolUse}; +use crate::models::message::{MessageType, SlashCommandData, ToolResult, ToolUse}; use crate::models::{ChatSession, Message, MessageRole}; use crate::models::{Provider, SessionState}; use super::project_inference::ProjectInference; +lazy_static! { + /// Regex patterns for parsing XML command blocks + static ref COMMAND_NAME_RE: Regex = + Regex::new(r"(.*?)").unwrap(); + static ref COMMAND_MESSAGE_RE: Regex = + Regex::new(r"(.*?)").unwrap(); + static ref COMMAND_ARGS_RE: Regex = + Regex::new(r"(.*?)").unwrap(); + static ref COMMAND_STDOUT_RE: Regex = + Regex::new(r"(?s)(.*?)").unwrap(); +} + #[derive(Debug, Serialize, Deserialize)] pub struct ClaudeCodeMessage { pub uuid: String, @@ -231,7 +245,7 @@ impl ClaudeCodeParser { .and_then(|ts| self.parse_timestamp(ts).ok()) .unwrap_or(start_time); - let (content, tool_uses, mut tool_results, thinking_content) = + let (content, tool_uses, mut tool_results, thinking_content, is_slash_command) = self.extract_tools_and_content(&conv_message.content); // If there's thinking content, create a separate message for it first @@ -265,8 +279,11 @@ impl ClaudeCodeParser { } } - // Skip messages with no meaningful content and no tools - if content == "[No content]" && tool_uses.is_empty() && tool_results.is_empty() + // Skip messages with no meaningful content and no tools and no slash command + if content == "[No content]" + && tool_uses.is_empty() + && tool_results.is_empty() + && !is_slash_command { continue; } @@ -275,6 +292,11 @@ impl ClaudeCodeParser { message.id = message_id; + // Set message type for slash commands + if is_slash_command { + message = message.with_message_type(MessageType::SlashCommand); + } + // Attach tool uses and results if any if !tool_uses.is_empty() { message = message.with_tool_uses(tool_uses); @@ -306,12 +328,61 @@ impl ClaudeCodeParser { Ok((chat_session, messages)) } + /// Parse XML slash command blocks from message content + /// Returns (is_slash_command, SlashCommandData if found) + fn parse_slash_command(&self, content: &str) -> Option { + // Check if content contains command-name tag + let command_name = COMMAND_NAME_RE + .captures(content) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string())?; + + let command_message = COMMAND_MESSAGE_RE + .captures(content) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + .filter(|s| !s.is_empty()); + + let command_args = COMMAND_ARGS_RE + .captures(content) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + .filter(|s| !s.is_empty()); + + let stdout = COMMAND_STDOUT_RE + .captures(content) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + .filter(|s| !s.is_empty()); + + Some(SlashCommandData { + command_name, + command_message, + command_args, + stdout, + }) + } + + /// Formats SlashCommandData into a clean, human-readable string + fn format_slash_command_content(&self, cmd: &SlashCommandData) -> String { + let mut parts = vec![cmd.command_name.clone()]; + + if let Some(ref stdout) = cmd.stdout { + let trimmed = stdout.trim(); + if !trimmed.is_empty() { + parts.push(trimmed.to_string()); + } + } + + parts.join("\n") + } + /// Extract tools and content from a Claude Code message value - /// Returns (content_string, tool_uses, tool_results, thinking_content) + /// Returns (content_string, tool_uses, tool_results, thinking_content, is_slash_command) fn extract_tools_and_content( &self, value: &Value, - ) -> (String, Vec, Vec, Option) { + ) -> (String, Vec, Vec, Option, bool) { let mut tool_uses = Vec::new(); let mut tool_results = Vec::new(); let mut thinking_content: Option = None; @@ -418,14 +489,25 @@ impl ClaudeCodeParser { _ => value.to_string(), }; - // Ensure content is never empty to satisfy database constraint - let final_content = if content.trim().is_empty() { + // Check for slash command blocks and format content if found + let slash_command = self.parse_slash_command(&content); + + // Format content: use formatted slash command or original content + let final_content = if let Some(ref cmd) = slash_command { + self.format_slash_command_content(cmd) + } else if content.trim().is_empty() { "[No content]".to_string() } else { content }; - (final_content, tool_uses, tool_results, thinking_content) + ( + final_content, + tool_uses, + tool_results, + thinking_content, + slash_command.is_some(), + ) } fn convert_session( @@ -508,7 +590,7 @@ impl ClaudeCodeParser { _ => return Err(anyhow!("Unknown message role: {}", claude_message.role)), }; - let (content, tool_uses, tool_results, _thinking_content) = + let (content, tool_uses, tool_results, _thinking_content, is_slash_command) = self.extract_tools_and_content(&claude_message.content); // Note: thinking_content is ignored for legacy format @@ -518,6 +600,11 @@ impl ClaudeCodeParser { message.id = message_id; + // Set message type for slash commands + if is_slash_command { + message = message.with_message_type(MessageType::SlashCommand); + } + // Attach tool uses and results if any if !tool_uses.is_empty() { message = message.with_tool_uses(tool_uses); @@ -890,4 +977,98 @@ mod tests { // Should have inferred the project name from the path assert_eq!(session.project_name, Some("testproject".to_string())); } + + #[test] + fn test_parse_slash_command_clear() { + let content = "/clear\nclear\n\nCleared conversation history."; + let parser = ClaudeCodeParser::new(std::path::Path::new("test.jsonl")); + let result = parser.parse_slash_command(content); + + assert!(result.is_some()); + let cmd = result.unwrap(); + assert_eq!(cmd.command_name, "/clear"); + assert_eq!(cmd.command_message, Some("clear".to_string())); + assert!(cmd.command_args.is_none()); // Empty args should be None + assert_eq!( + cmd.stdout, + Some("Cleared conversation history.".to_string()) + ); + } + + #[test] + fn test_parse_slash_command_help() { + let content = "/help\nhelp\n"; + let parser = ClaudeCodeParser::new(std::path::Path::new("test.jsonl")); + let result = parser.parse_slash_command(content); + + assert!(result.is_some()); + let cmd = result.unwrap(); + assert_eq!(cmd.command_name, "/help"); + assert_eq!(cmd.command_message, Some("help".to_string())); + assert!(cmd.command_args.is_none()); + assert!(cmd.stdout.is_none()); + } + + #[test] + fn test_parse_slash_command_with_args() { + let content = "/model\nmodel\nopus"; + let parser = ClaudeCodeParser::new(std::path::Path::new("test.jsonl")); + let result = parser.parse_slash_command(content); + + assert!(result.is_some()); + let cmd = result.unwrap(); + assert_eq!(cmd.command_name, "/model"); + assert_eq!(cmd.command_message, Some("model".to_string())); + assert_eq!(cmd.command_args, Some("opus".to_string())); + assert!(cmd.stdout.is_none()); + } + + #[test] + fn test_parse_slash_command_multiline_stdout() { + let content = "/doctor\ndoctor\n\nLine 1\nLine 2\nLine 3"; + let parser = ClaudeCodeParser::new(std::path::Path::new("test.jsonl")); + let result = parser.parse_slash_command(content); + + assert!(result.is_some()); + let cmd = result.unwrap(); + assert_eq!(cmd.command_name, "/doctor"); + assert!(cmd.stdout.is_some()); + assert!(cmd.stdout.unwrap().contains('\n')); + } + + #[test] + fn test_parse_slash_command_no_command() { + let content = "This is just regular text without any command blocks."; + let parser = ClaudeCodeParser::new(std::path::Path::new("test.jsonl")); + let result = parser.parse_slash_command(content); + + assert!(result.is_none()); + } + + #[test] + fn test_message_type_slash_command_display() { + use crate::models::message::MessageType; + let mt = MessageType::SlashCommand; + assert_eq!(mt.to_string(), "slash_command"); + } + + #[test] + fn test_message_type_slash_command_from_str() { + use crate::models::message::MessageType; + use std::str::FromStr; + + let mt = MessageType::from_str("slash_command").unwrap(); + assert_eq!(mt, MessageType::SlashCommand); + } + + #[test] + fn test_message_type_slash_command_roundtrip() { + use crate::models::message::MessageType; + use std::str::FromStr; + + let original = MessageType::SlashCommand; + let serialized = original.to_string(); + let deserialized = MessageType::from_str(&serialized).unwrap(); + assert_eq!(original, deserialized); + } } diff --git a/crates/retrochat-core/src/services/analytics/data_collector.rs b/crates/retrochat-core/src/services/analytics/data_collector.rs index a33289a..714957d 100644 --- a/crates/retrochat-core/src/services/analytics/data_collector.rs +++ b/crates/retrochat-core/src/services/analytics/data_collector.rs @@ -143,6 +143,7 @@ fn get_message_type_string( format!("tool_result({})", tool_name) } MessageType::Thinking => "thinking".to_string(), + MessageType::SlashCommand => "slash_command".to_string(), MessageType::SimpleMessage => "simple_message".to_string(), } } diff --git a/crates/retrochat-core/src/services/query_service.rs b/crates/retrochat-core/src/services/query_service.rs index 079312e..2b1aff9 100644 --- a/crates/retrochat-core/src/services/query_service.rs +++ b/crates/retrochat-core/src/services/query_service.rs @@ -13,11 +13,11 @@ use uuid::Uuid; #[derive(Debug, Clone)] pub enum MessageGroup { /// A single standalone message - Single(Message), + Single(Box), /// A tool use message paired with its corresponding tool result message ToolPair { - tool_use_message: Message, - tool_result_message: Message, + tool_use_message: Box, + tool_result_message: Box, }, } @@ -70,8 +70,8 @@ impl MessageGroup { if has_matching_result { // Create a ToolPair and skip the next message groups.push(MessageGroup::ToolPair { - tool_use_message: current.clone(), - tool_result_message: next.clone(), + tool_use_message: Box::new(current.clone()), + tool_result_message: Box::new(next.clone()), }); i += 2; // Skip both messages continue; @@ -81,7 +81,7 @@ impl MessageGroup { } // Not a pair, add as single - groups.push(MessageGroup::Single(current.clone())); + groups.push(MessageGroup::Single(Box::new(current.clone()))); i += 1; } diff --git a/crates/retrochat-tui/src/session_detail.rs b/crates/retrochat-tui/src/session_detail.rs index db950d4..dec6b42 100644 --- a/crates/retrochat-tui/src/session_detail.rs +++ b/crates/retrochat-tui/src/session_detail.rs @@ -352,8 +352,9 @@ impl SessionDetailWidget { /// Renders a single message block fn render_message_block(&self, message: &Message, width: usize, lines: &mut Vec>) { - // Check if this is a thinking message + // Check message types let is_thinking = message.is_thinking(); + let is_slash_command = message.is_slash_command(); // Message header let role_style = if is_thinking { @@ -361,6 +362,11 @@ impl SessionDetailWidget { Style::default() .fg(Color::Magenta) .add_modifier(Modifier::BOLD | Modifier::ITALIC) + } else if is_slash_command { + // Slash command messages have yellow/amber styling + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { match message.role { MessageRole::User => Style::default() @@ -378,6 +384,8 @@ impl SessionDetailWidget { let timestamp = &message.timestamp.format("%H:%M:%S").to_string(); // Just show time let header = if is_thinking { format!("[{timestamp}] Thinking") + } else if is_slash_command { + format!("[{timestamp}] Slash Command") } else { format!("[{timestamp}] {:?}", message.role) }; @@ -390,11 +398,13 @@ impl SessionDetailWidget { // Message content - wrap text and preserve newlines let content_lines = wrap_text(&message.content, width.saturating_sub(2)); - // Use different styling for thinking content + // Use different styling for thinking and slash command content let content_style = if is_thinking { Style::default() .fg(Color::Rgb(180, 140, 200)) // Light purple for thinking .add_modifier(Modifier::ITALIC) + } else if is_slash_command { + Style::default().fg(Color::Rgb(200, 180, 100)) // Golden/amber for slash commands } else { Style::default().fg(Color::White) }; diff --git a/ui-react/src/components/session-detail.tsx b/ui-react/src/components/session-detail.tsx index 7158f23..516e9b6 100644 --- a/ui-react/src/components/session-detail.tsx +++ b/ui-react/src/components/session-detail.tsx @@ -6,7 +6,16 @@ import { ReloadIcon, } from '@radix-ui/react-icons' import { format, formatDistanceToNow } from 'date-fns' -import { Bot, Brain, Check, Copy, FileCode, MessageSquare, TrendingUp } from 'lucide-react' +import { + Bot, + Brain, + Check, + Copy, + FileCode, + MessageSquare, + Terminal, + TrendingUp, +} from 'lucide-react' import { useCallback, useEffect, useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import ReactMarkdown from 'react-markdown' @@ -149,6 +158,10 @@ function MessageRenderer({ message }: { message: SessionWithMessages['messages'] return } + if (messageType === 'slash_command') { + return + } + return } @@ -194,6 +207,57 @@ function ThinkingMessage({ message }: { message: SessionWithMessages['messages'] ) } +function SlashCommandMessage({ message }: { message: SessionWithMessages['messages'][0] }) { + const [isOpen, setIsOpen] = useState(false) + + // Extract command name from content if available + const commandName = + message.content.match(/\[Slash Command: (.*?)\]/)?.[1] || + message.content.match(/(.*?)<\/command-name>/)?.[1] || + 'Command' + + return ( +
+
+
+ +
+
+ + + + + +
+

+ {message.content} +

+
+
+
+
+
+
+ ) +} + function ToolRequestMessage({ message }: { message: SessionWithMessages['messages'][0] }) { const [isOpen, setIsOpen] = useState(false) From 16f49882574b6e510a10005d87cc7e1d9da2de75 Mon Sep 17 00:00:00 2001 From: sanggggg Date: Fri, 26 Dec 2025 14:32:08 +0900 Subject: [PATCH 2/2] feat(parser): split Gemini CLI thoughts into separate thinking messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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 --- .../retrochat-core/src/parsers/gemini_cli.rs | 315 +++++++++++++++++- 1 file changed, 297 insertions(+), 18 deletions(-) diff --git a/crates/retrochat-core/src/parsers/gemini_cli.rs b/crates/retrochat-core/src/parsers/gemini_cli.rs index 83e8cc4..17630fe 100644 --- a/crates/retrochat-core/src/parsers/gemini_cli.rs +++ b/crates/retrochat-core/src/parsers/gemini_cli.rs @@ -7,6 +7,7 @@ use std::io::Read; use std::path::Path; use uuid::Uuid; +use crate::models::message::MessageType; use crate::models::{ChatSession, Message, MessageRole, ToolResult, ToolUse}; use crate::models::{Provider, SessionState}; use crate::parsers::project_inference::ProjectInference; @@ -279,15 +280,19 @@ impl GeminiCLIParser { // Convert messages let mut converted_messages = Vec::new(); let mut total_tokens = 0u32; + let mut sequence = 1usize; - for (index, session_message) in messages.iter().enumerate() { - let message = self.convert_session_message(session_message, session_id, index + 1)?; + for session_message in messages.iter() { + let new_messages = + self.convert_session_message(session_message, session_id, sequence)?; - if let Some(token_count) = message.token_count { - total_tokens += token_count; + for message in new_messages { + if let Some(token_count) = message.token_count { + total_tokens += token_count; + } + sequence += 1; + converted_messages.push(message); } - - converted_messages.push(message); } chat_session.message_count = converted_messages.len() as u32; @@ -342,15 +347,19 @@ impl GeminiCLIParser { let mut messages = Vec::new(); let mut total_tokens = 0u32; + let mut sequence = 1usize; - for (index, session_message) in session.messages.iter().enumerate() { - let message = self.convert_session_message(session_message, session_id, index + 1)?; + for session_message in session.messages.iter() { + let new_messages = + self.convert_session_message(session_message, session_id, sequence)?; - if let Some(token_count) = message.token_count { - total_tokens += token_count; + for message in new_messages { + if let Some(token_count) = message.token_count { + total_tokens += token_count; + } + sequence += 1; + messages.push(message); } - - messages.push(message); } chat_session.message_count = messages.len() as u32; @@ -555,12 +564,14 @@ impl GeminiCLIParser { (tool_uses, tool_results) } + /// Convert a Gemini session message to one or more Messages. + /// Returns multiple messages when thoughts are present (thinking messages + main message). fn convert_session_message( &self, session_message: &GeminiSessionMessage, session_id: Uuid, - sequence: usize, - ) -> Result { + start_sequence: usize, + ) -> Result> { let role = match session_message.message_type.as_str() { "user" => MessageRole::User, "gemini" => MessageRole::Assistant, @@ -579,7 +590,49 @@ impl GeminiCLIParser { let timestamp = self.parse_timestamp(&session_message.timestamp)?; - // Generate a deterministic UUID for the message + let mut messages = Vec::new(); + let mut current_sequence = start_sequence; + + // Create separate thinking messages for each thought (only for assistant messages) + if role == MessageRole::Assistant { + if let Some(thoughts) = &session_message.thoughts { + for (idx, thought) in thoughts.iter().enumerate() { + let thinking_content = + format!("**{}**\n\n{}", thought.subject, thought.description); + let thinking_timestamp = self + .parse_timestamp(&thought.timestamp) + .unwrap_or(timestamp); + + // Generate deterministic UUID for thinking message using index to avoid collision + let thinking_id = self.generate_uuid_from_string(&format!( + "{session_id}-thought-{}-{}", + session_message.id, idx + )); + + let mut thinking_message = Message::new( + session_id, + MessageRole::Assistant, + thinking_content, + thinking_timestamp, + current_sequence as u32, + ) + .with_message_type(MessageType::Thinking); + + thinking_message.id = thinking_id; + + // Token estimation for thinking content + let thinking_tokens = (thinking_message.content.len() / 4) as u32; + if thinking_tokens > 0 { + thinking_message = thinking_message.with_token_count(thinking_tokens); + } + + messages.push(thinking_message); + current_sequence += 1; + } + } + } + + // Generate a deterministic UUID for the main message let message_id = if let Ok(uuid) = Uuid::parse_str(&session_message.id) { uuid } else { @@ -591,7 +644,7 @@ impl GeminiCLIParser { role, session_message.content.clone(), timestamp, - sequence as u32, + current_sequence as u32, ); message.id = message_id; @@ -608,13 +661,15 @@ impl GeminiCLIParser { message = message.with_token_count(tokens.total); } else { // Estimate token count based on content length - let estimated_tokens = (message.content.len() / 4) as u32; // Rough estimate: 4 chars per token + let estimated_tokens = (message.content.len() / 4) as u32; if estimated_tokens > 0 { message = message.with_token_count(estimated_tokens); } } - Ok(message) + messages.push(message); + + Ok(messages) } async fn convert_conversation( @@ -1027,4 +1082,228 @@ mod tests { assert_eq!(count, 2); } + + #[tokio::test] + async fn test_parse_gemini_session_with_thoughts() { + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("session-test-thoughts.json"); + + let sample_data = r#"{ + "sessionId": "test-session-123", + "projectHash": "abc123", + "startTime": "2024-01-01T10:00:00Z", + "lastUpdated": "2024-01-01T10:05:00Z", + "messages": [ + { + "id": "msg-1", + "timestamp": "2024-01-01T10:00:00Z", + "type": "user", + "content": "Hello" + }, + { + "id": "msg-2", + "timestamp": "2024-01-01T10:01:00Z", + "type": "gemini", + "content": "Hi there!", + "thoughts": [ + { + "subject": "Analyzing Request", + "description": "I'm analyzing the user's greeting.", + "timestamp": "2024-01-01T10:00:30Z" + }, + { + "subject": "Formulating Response", + "description": "I'll respond with a friendly greeting.", + "timestamp": "2024-01-01T10:00:45Z" + } + ] + } + ] + }"#; + + fs::write(&file_path, sample_data).unwrap(); + + let parser = GeminiCLIParser::new(&file_path); + let result = parser.parse().await; + + assert!(result.is_ok()); + let sessions = result.unwrap(); + assert_eq!(sessions.len(), 1); + + let (session, messages) = &sessions[0]; + + // Should have 4 messages: 1 user + 2 thinking + 1 main assistant + assert_eq!(messages.len(), 4); + assert_eq!(session.message_count, 4); + + // First message: user + assert_eq!(messages[0].role, MessageRole::User); + assert_eq!(messages[0].content, "Hello"); + assert_eq!(messages[0].sequence_number, 1); + + // Second message: thinking 1 + assert_eq!(messages[1].role, MessageRole::Assistant); + assert_eq!(messages[1].message_type, MessageType::Thinking); + assert!(messages[1].content.contains("**Analyzing Request**")); + assert!(messages[1] + .content + .contains("analyzing the user's greeting")); + assert_eq!(messages[1].sequence_number, 2); + + // Third message: thinking 2 + assert_eq!(messages[2].role, MessageRole::Assistant); + assert_eq!(messages[2].message_type, MessageType::Thinking); + assert!(messages[2].content.contains("**Formulating Response**")); + assert_eq!(messages[2].sequence_number, 3); + + // Fourth message: main assistant response + assert_eq!(messages[3].role, MessageRole::Assistant); + assert_eq!(messages[3].message_type, MessageType::SimpleMessage); + assert_eq!(messages[3].content, "Hi there!"); + assert_eq!(messages[3].sequence_number, 4); + } + + #[tokio::test] + async fn test_parse_gemini_session_no_thoughts() { + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("session-no-thoughts.json"); + + let sample_data = r#"{ + "sessionId": "test-session-456", + "projectHash": "def456", + "startTime": "2024-01-01T10:00:00Z", + "lastUpdated": "2024-01-01T10:02:00Z", + "messages": [ + { + "id": "msg-1", + "timestamp": "2024-01-01T10:00:00Z", + "type": "user", + "content": "Hello" + }, + { + "id": "msg-2", + "timestamp": "2024-01-01T10:01:00Z", + "type": "gemini", + "content": "Hi there!" + } + ] + }"#; + + fs::write(&file_path, sample_data).unwrap(); + + let parser = GeminiCLIParser::new(&file_path); + let result = parser.parse().await; + + assert!(result.is_ok()); + let sessions = result.unwrap(); + let (session, messages) = &sessions[0]; + + // Should have 2 messages: 1 user + 1 assistant (no thinking) + assert_eq!(messages.len(), 2); + assert_eq!(session.message_count, 2); + + assert_eq!(messages[0].role, MessageRole::User); + assert_eq!(messages[1].role, MessageRole::Assistant); + assert_eq!(messages[1].message_type, MessageType::SimpleMessage); + } + + #[tokio::test] + async fn test_parse_gemini_session_user_message_ignores_thoughts() { + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("session-user-thoughts.json"); + + // Even if user messages somehow have thoughts, they should be ignored + let sample_data = r#"{ + "sessionId": "test-session-789", + "projectHash": "ghi789", + "startTime": "2024-01-01T10:00:00Z", + "lastUpdated": "2024-01-01T10:01:00Z", + "messages": [ + { + "id": "msg-1", + "timestamp": "2024-01-01T10:00:00Z", + "type": "user", + "content": "Hello", + "thoughts": [ + { + "subject": "Should Be Ignored", + "description": "This thought should not create a message.", + "timestamp": "2024-01-01T10:00:00Z" + } + ] + } + ] + }"#; + + fs::write(&file_path, sample_data).unwrap(); + + let parser = GeminiCLIParser::new(&file_path); + let result = parser.parse().await; + + assert!(result.is_ok()); + let sessions = result.unwrap(); + let (session, messages) = &sessions[0]; + + // Should have only 1 message (user thoughts are ignored) + assert_eq!(messages.len(), 1); + assert_eq!(session.message_count, 1); + assert_eq!(messages[0].role, MessageRole::User); + } + + #[tokio::test] + async fn test_parse_gemini_session_empty_thoughts_array() { + use std::fs; + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("session-empty-thoughts.json"); + + // Empty thoughts array should not create any thinking messages + let sample_data = r#"{ + "sessionId": "test-session-empty", + "projectHash": "empty123", + "startTime": "2024-01-01T10:00:00Z", + "lastUpdated": "2024-01-01T10:01:00Z", + "messages": [ + { + "id": "msg-1", + "timestamp": "2024-01-01T10:00:00Z", + "type": "user", + "content": "Hello" + }, + { + "id": "msg-2", + "timestamp": "2024-01-01T10:01:00Z", + "type": "gemini", + "content": "Hi there!", + "thoughts": [] + } + ] + }"#; + + fs::write(&file_path, sample_data).unwrap(); + + let parser = GeminiCLIParser::new(&file_path); + let result = parser.parse().await; + + assert!(result.is_ok()); + let sessions = result.unwrap(); + let (session, messages) = &sessions[0]; + + // Should have only 2 messages (no thinking for empty array) + assert_eq!(messages.len(), 2); + assert_eq!(session.message_count, 2); + assert_eq!(messages[0].role, MessageRole::User); + assert_eq!(messages[1].role, MessageRole::Assistant); + assert_eq!(messages[1].message_type, MessageType::SimpleMessage); + } }