generated from nhcarrigan/template
d7b1ff44c4
Parse structured WorktreeInfo (name, path, branch, original_repo_directory) from WorktreeCreate/Remove hook events and emit a dedicated claude:worktree event. Store per-conversation worktree state and display an emerald branch badge in the status bar so users can see at a glance which worktree and branch each session is running on. Closes #206
570 lines
17 KiB
Rust
570 lines
17 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct UsageInfo {
|
|
pub input_tokens: u64,
|
|
pub output_tokens: u64,
|
|
#[serde(default)]
|
|
pub cache_creation_input_tokens: Option<u64>,
|
|
#[serde(default)]
|
|
pub cache_read_input_tokens: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum CharacterState {
|
|
#[default]
|
|
Idle,
|
|
Thinking,
|
|
Typing,
|
|
Searching,
|
|
Coding,
|
|
Mcp,
|
|
Permission,
|
|
Success,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ConnectionStatus {
|
|
#[default]
|
|
Disconnected,
|
|
Connecting,
|
|
Connected,
|
|
Error,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TerminalLine {
|
|
pub id: String,
|
|
#[serde(rename = "type")]
|
|
pub line_type: String,
|
|
pub content: String,
|
|
pub timestamp: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub tool_name: Option<String>,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PermissionRequest {
|
|
pub id: String,
|
|
pub tool: String,
|
|
pub description: String,
|
|
pub input: serde_json::Value,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PermissionDenial {
|
|
pub tool_name: String,
|
|
pub tool_use_id: String,
|
|
pub tool_input: serde_json::Value,
|
|
}
|
|
|
|
/// Rate limit information from a `rate_limit_event` message.
|
|
/// All fields are optional to ensure forward-compatibility as the Claude CLI evolves.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct RateLimitInfo {
|
|
#[serde(default)]
|
|
pub requests_limit: Option<u64>,
|
|
#[serde(default)]
|
|
pub requests_remaining: Option<u64>,
|
|
#[serde(default)]
|
|
pub requests_reset: Option<String>,
|
|
#[serde(default)]
|
|
pub tokens_limit: Option<u64>,
|
|
#[serde(default)]
|
|
pub tokens_remaining: Option<u64>,
|
|
#[serde(default)]
|
|
pub tokens_reset: Option<String>,
|
|
#[serde(default)]
|
|
pub retry_after_ms: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type")]
|
|
pub enum ClaudeMessage {
|
|
#[serde(rename = "system")]
|
|
System {
|
|
subtype: String,
|
|
#[serde(default)]
|
|
session_id: Option<String>,
|
|
#[serde(default)]
|
|
cwd: Option<String>,
|
|
#[serde(default)]
|
|
tools: Option<Vec<String>>,
|
|
},
|
|
#[serde(rename = "assistant")]
|
|
Assistant {
|
|
message: AssistantMessageContent,
|
|
#[serde(default)]
|
|
parent_tool_use_id: Option<String>,
|
|
},
|
|
#[serde(rename = "user")]
|
|
User { message: UserMessageContent },
|
|
#[serde(rename = "stream_event")]
|
|
StreamEvent { event: StreamEventData },
|
|
#[serde(rename = "result")]
|
|
Result {
|
|
subtype: String,
|
|
#[serde(default)]
|
|
result: Option<String>,
|
|
#[serde(default)]
|
|
duration_ms: Option<u64>,
|
|
#[serde(default)]
|
|
num_turns: Option<u32>,
|
|
#[serde(default)]
|
|
permission_denials: Option<Vec<PermissionDenial>>,
|
|
#[serde(default)]
|
|
usage: Option<UsageInfo>,
|
|
},
|
|
#[serde(rename = "rate_limit_event")]
|
|
RateLimitEvent {
|
|
#[serde(default)]
|
|
rate_limit_info: RateLimitInfo,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AssistantMessageContent {
|
|
pub content: Vec<ContentBlock>,
|
|
#[serde(default)]
|
|
pub model: Option<String>,
|
|
#[serde(default)]
|
|
pub stop_reason: Option<String>,
|
|
#[serde(default)]
|
|
pub usage: Option<UsageInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct UserMessageContent {
|
|
pub content: Vec<ContentBlock>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type")]
|
|
pub enum ContentBlock {
|
|
#[serde(rename = "text")]
|
|
Text { text: String },
|
|
#[serde(rename = "thinking")]
|
|
Thinking { thinking: String },
|
|
#[serde(rename = "tool_use")]
|
|
ToolUse {
|
|
id: String,
|
|
name: String,
|
|
input: serde_json::Value,
|
|
},
|
|
#[serde(rename = "tool_result")]
|
|
ToolResult {
|
|
tool_use_id: String,
|
|
content: serde_json::Value,
|
|
#[serde(default)]
|
|
is_error: Option<bool>,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StreamEventData {
|
|
#[serde(rename = "type")]
|
|
pub event_type: String,
|
|
#[serde(default)]
|
|
pub index: Option<u32>,
|
|
#[serde(default)]
|
|
pub content_block: Option<ContentBlockStart>,
|
|
#[serde(default)]
|
|
pub delta: Option<DeltaContent>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ContentBlockStart {
|
|
#[serde(rename = "type")]
|
|
pub block_type: String,
|
|
#[serde(default)]
|
|
pub id: Option<String>,
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DeltaContent {
|
|
#[serde(rename = "type")]
|
|
pub delta_type: String,
|
|
#[serde(default)]
|
|
pub text: Option<String>,
|
|
#[serde(default)]
|
|
pub thinking: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StateChangeEvent {
|
|
pub state: CharacterState,
|
|
pub tool_name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
/// Cost information for a message
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MessageCost {
|
|
pub input_tokens: u64,
|
|
pub output_tokens: u64,
|
|
pub cost_usd: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct OutputEvent {
|
|
pub line_type: String,
|
|
pub content: String,
|
|
pub tool_name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub cost: Option<MessageCost>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub parent_tool_use_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PermissionPromptEventItem {
|
|
pub id: String,
|
|
pub tool_name: String,
|
|
pub tool_input: serde_json::Value,
|
|
pub description: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PermissionPromptEvent {
|
|
pub permissions: Vec<PermissionPromptEventItem>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ConnectionEvent {
|
|
pub status: ConnectionStatus,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SessionEvent {
|
|
pub session_id: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WorkingDirectoryEvent {
|
|
pub directory: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct QuestionOption {
|
|
pub label: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct UserQuestionEvent {
|
|
pub id: String,
|
|
pub question: String,
|
|
pub header: Option<String>,
|
|
pub options: Vec<QuestionOption>,
|
|
pub multi_select: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AgentStartEvent {
|
|
pub tool_use_id: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub agent_id: Option<String>,
|
|
pub description: String,
|
|
pub subagent_type: String,
|
|
pub started_at: u64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub parent_tool_use_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub model: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WorktreeInfo {
|
|
pub name: String,
|
|
pub path: String,
|
|
pub branch: String,
|
|
pub original_repo_directory: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WorktreeEvent {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
/// "create" or "remove"
|
|
pub event_type: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub worktree: Option<WorktreeInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AgentEndEvent {
|
|
pub tool_use_id: String,
|
|
pub ended_at: u64,
|
|
pub is_error: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub duration_ms: Option<u64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub num_turns: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub last_assistant_message: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TodoItem {
|
|
pub content: String,
|
|
pub status: String, // "pending", "in_progress", or "completed"
|
|
#[serde(rename = "activeForm")]
|
|
pub active_form: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TodoUpdateEvent {
|
|
pub todos: Vec<TodoItem>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_character_state_default() {
|
|
let state = CharacterState::default();
|
|
assert_eq!(state, CharacterState::Idle);
|
|
}
|
|
|
|
#[test]
|
|
fn test_connection_status_default() {
|
|
let status = ConnectionStatus::default();
|
|
matches!(status, ConnectionStatus::Disconnected);
|
|
}
|
|
|
|
#[test]
|
|
fn test_character_state_serialization() {
|
|
let state = CharacterState::Thinking;
|
|
let serialized = serde_json::to_string(&state).unwrap();
|
|
assert_eq!(serialized, "\"thinking\"");
|
|
|
|
let deserialized: CharacterState = serde_json::from_str(&serialized).unwrap();
|
|
assert_eq!(deserialized, CharacterState::Thinking);
|
|
}
|
|
|
|
#[test]
|
|
fn test_all_character_states_serialize() {
|
|
let states = vec![
|
|
(CharacterState::Idle, "\"idle\""),
|
|
(CharacterState::Thinking, "\"thinking\""),
|
|
(CharacterState::Typing, "\"typing\""),
|
|
(CharacterState::Searching, "\"searching\""),
|
|
(CharacterState::Coding, "\"coding\""),
|
|
(CharacterState::Mcp, "\"mcp\""),
|
|
(CharacterState::Permission, "\"permission\""),
|
|
(CharacterState::Success, "\"success\""),
|
|
(CharacterState::Error, "\"error\""),
|
|
];
|
|
|
|
for (state, expected) in states {
|
|
let serialized = serde_json::to_string(&state).unwrap();
|
|
assert_eq!(serialized, expected, "Failed for state: {:?}", state);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_terminal_line_serialization() {
|
|
let line = TerminalLine {
|
|
id: "test-123".to_string(),
|
|
line_type: "assistant".to_string(),
|
|
content: "Hello, world!".to_string(),
|
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
|
tool_name: None,
|
|
};
|
|
|
|
let serialized = serde_json::to_string(&line).unwrap();
|
|
assert!(serialized.contains("\"type\":\"assistant\""));
|
|
assert!(serialized.contains("\"content\":\"Hello, world!\""));
|
|
assert!(!serialized.contains("tool_name"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_terminal_line_with_tool_name() {
|
|
let line = TerminalLine {
|
|
id: "test-456".to_string(),
|
|
line_type: "tool".to_string(),
|
|
content: "Reading file...".to_string(),
|
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
|
tool_name: Some("Read".to_string()),
|
|
};
|
|
|
|
let serialized = serde_json::to_string(&line).unwrap();
|
|
assert!(serialized.contains("\"tool_name\":\"Read\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_block_text() {
|
|
let block = ContentBlock::Text {
|
|
text: "Hello!".to_string(),
|
|
};
|
|
|
|
let serialized = serde_json::to_string(&block).unwrap();
|
|
assert!(serialized.contains("\"type\":\"text\""));
|
|
assert!(serialized.contains("\"text\":\"Hello!\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_block_tool_use() {
|
|
let block = ContentBlock::ToolUse {
|
|
id: "tool-123".to_string(),
|
|
name: "Read".to_string(),
|
|
input: serde_json::json!({"file_path": "/test.txt"}),
|
|
};
|
|
|
|
let serialized = serde_json::to_string(&block).unwrap();
|
|
assert!(serialized.contains("\"type\":\"tool_use\""));
|
|
assert!(serialized.contains("\"name\":\"Read\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_state_change_event() {
|
|
let event = StateChangeEvent {
|
|
state: CharacterState::Coding,
|
|
tool_name: Some("Edit".to_string()),
|
|
conversation_id: None,
|
|
};
|
|
|
|
let serialized = serde_json::to_string(&event).unwrap();
|
|
assert!(serialized.contains("\"state\":\"coding\""));
|
|
assert!(serialized.contains("\"tool_name\":\"Edit\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_output_event() {
|
|
let event = OutputEvent {
|
|
line_type: "assistant".to_string(),
|
|
content: "Test output".to_string(),
|
|
tool_name: None,
|
|
conversation_id: None,
|
|
cost: None,
|
|
parent_tool_use_id: None,
|
|
};
|
|
|
|
let serialized = serde_json::to_string(&event).unwrap();
|
|
assert!(serialized.contains("\"line_type\":\"assistant\""));
|
|
assert!(serialized.contains("\"content\":\"Test output\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_output_event_with_cost() {
|
|
let event = OutputEvent {
|
|
line_type: "assistant".to_string(),
|
|
content: "Test output".to_string(),
|
|
tool_name: None,
|
|
conversation_id: Some("conv-123".to_string()),
|
|
cost: Some(MessageCost {
|
|
input_tokens: 100,
|
|
output_tokens: 50,
|
|
cost_usd: 0.005,
|
|
}),
|
|
parent_tool_use_id: None,
|
|
};
|
|
|
|
let serialized = serde_json::to_string(&event).unwrap();
|
|
assert!(serialized.contains("\"cost\":"));
|
|
assert!(serialized.contains("\"input_tokens\":100"));
|
|
assert!(serialized.contains("\"output_tokens\":50"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rate_limit_info_default() {
|
|
let info = RateLimitInfo::default();
|
|
assert!(info.requests_limit.is_none());
|
|
assert!(info.requests_remaining.is_none());
|
|
assert!(info.requests_reset.is_none());
|
|
assert!(info.tokens_limit.is_none());
|
|
assert!(info.tokens_remaining.is_none());
|
|
assert!(info.tokens_reset.is_none());
|
|
assert!(info.retry_after_ms.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_rate_limit_event_deserialization_empty_info() {
|
|
let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#;
|
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rate_limit_event_deserialization_no_info() {
|
|
// rate_limit_info field is optional via #[serde(default)]
|
|
let json = r#"{"type":"rate_limit_event"}"#;
|
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rate_limit_event_deserialization_with_data() {
|
|
let json = r#"{
|
|
"type": "rate_limit_event",
|
|
"rate_limit_info": {
|
|
"requests_limit": 1000,
|
|
"requests_remaining": 0,
|
|
"requests_reset": "2024-01-01T00:01:00Z",
|
|
"tokens_limit": 50000,
|
|
"tokens_remaining": 0,
|
|
"tokens_reset": "2024-01-01T00:01:00Z",
|
|
"retry_after_ms": 60000
|
|
}
|
|
}"#;
|
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
|
assert_eq!(rate_limit_info.requests_limit, Some(1000));
|
|
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
|
assert_eq!(
|
|
rate_limit_info.requests_reset,
|
|
Some("2024-01-01T00:01:00Z".to_string())
|
|
);
|
|
assert_eq!(rate_limit_info.retry_after_ms, Some(60000));
|
|
} else {
|
|
panic!("Expected RateLimitEvent variant");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_rate_limit_event_ignores_unknown_fields() {
|
|
// Ensures forward-compat: unknown fields in rate_limit_info are silently ignored
|
|
let json = r#"{
|
|
"type": "rate_limit_event",
|
|
"rate_limit_info": {
|
|
"requests_remaining": 0,
|
|
"some_future_field": "some_value"
|
|
}
|
|
}"#;
|
|
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
|
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
|
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
|
} else {
|
|
panic!("Expected RateLimitEvent variant");
|
|
}
|
|
}
|
|
}
|