generated from nhcarrigan/template
f173892aaa
## Summary This PR includes major feature additions, bug fixes, comprehensive testing improvements, and responsive design enhancements! ## New Features โจ ### Plugin & MCP Management (#133, #134) - **Plugin Management Panel**: Install, uninstall, enable/disable, and update plugins - **MCP Server Management Panel**: Add/remove MCP servers, view detailed configuration - **Marketplace Management**: Add/remove plugin marketplaces from GitHub - Backend commands for full CLI integration (`list_plugins`, `install_plugin`, `add_mcp_server`, etc.) - Beautiful UI with proper loading states, error handling, and theme support ### Visual Todo List Panel (#132) - Real-time todo list display when Hikari uses the `TodoWrite` tool - Shows pending/in-progress/completed status with visual indicators - Progress bar and completion count - Automatically clears on disconnect - Theme-aware styling ### Clear Session History Button (#130) - "Clear All Sessions" button in Session History panel - Confirmation dialog with session count - Keyboard support and accessibility features - Gives users control over disk usage ### CLI Version Display (#131) - Displays Claude CLI version in status bar - Auto-polls every 30 seconds for updates - Useful for debugging and feature compatibility ## Bug Fixes ๐ ### Stats Panel Scrolling (#136) - **Fixed stats panel overflow**: Added scrollable container with `max-height` constraint - Stats panel now scrolls when content (Tools Used, Historical Costs, Budget sections) gets too long - Prevents content from overflowing off screen ### Agent Monitor Fixes (#122) - **Fixed agents stuck in "running" state**: Added `SubagentStop` hook parsing - **Fixed agents persisting after disconnect**: Call `clearConversation()` on disconnect - **Fixed "Kill All" button**: Now properly marks all agents as errored - **Fixed badge persisting after tab close**: Cleanup agents when conversation is deleted - Comprehensive tests for agent lifecycle management ### Discord RPC Cleanup (#129) - Removed file-based logging for Discord RPC - Replaced with proper `tracing` framework usage - Reduces disk usage and eliminates maintenance burden ### Close Modal Bug Fix (#128) - Fixed close confirmation modal not triggering after Discord RPC refactor - Removed frontend calls to deleted `log_discord_rpc` command - Modal now works correctly after all operations ### Responsive Design Fixes (#118) - Fixed top navigation icons getting cut off at small screen widths - Fixed Connect button disappearing on narrow screens - Fixed bottom status info (clock, CLI version) getting cut off - Added flex-wrap and mobile-optimised layouts - Icons-only mode on screens < 640px - Vertical stacking on screens < 768px ## Testing Improvements ๐งช ### Comprehensive Test Coverage (#114) - **417 backend tests** (up from 408) - **387 frontend tests** (up from 363) - **61%+ backend code coverage** - Added E2E integration tests for cross-platform notification commands - New test files: `agents.test.ts`, comprehensive CLI parsing tests - Tests for `debug_logger.rs`, `bridge_manager.rs`, `notifications.rs` - Console mocking for cleaner test output - Fixed flaky frontend tests ### Testing Documentation - Updated CLAUDE.md with comprehensive testing guidelines - Documented mocking approaches (console mocking, E2E command structure testing) - Added step-by-step guide for adding tests to new features - Goal to maintain ~100% test coverage documented ## Closes Closes #114 Closes #118 Closes #122 Closes #128 Closes #129 Closes #130 Closes #131 Closes #132 Closes #133 Closes #134 Closes #136 ## Technical Details - All new backend commands properly registered in `lib.rs` - CLI output parsing with comprehensive test coverage - Cross-platform compatibility verified through E2E tests (Linux CI can test Windows commands) - Theme-aware UI components using CSS variables throughout - Proper TypeScript types for all new stores and components - ESLint and Prettier compliant - All Clippy warnings addressed โจ This PR was created with help from Hikari~ ๐ธ Reviewed-on: #135 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
450 lines
13 KiB
Rust
450 lines
13 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,
|
|
}
|
|
|
|
#[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>,
|
|
},
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|