generated from nhcarrigan/template
feat: major feature additions and improvements (#135)
## 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>
This commit was merged in pull request #135.
This commit is contained in:
+258
-9
@@ -16,8 +16,8 @@ use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
|
||||
use crate::types::{
|
||||
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
|
||||
ConnectionStatus, ContentBlock, MessageCost, OutputEvent, PermissionPromptEvent,
|
||||
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent,
|
||||
UserQuestionEvent, WorkingDirectoryEvent,
|
||||
PermissionPromptEventItem, QuestionOption, SessionEvent, StateChangeEvent, TodoItem,
|
||||
TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
use std::cell::RefCell;
|
||||
@@ -678,6 +678,34 @@ fn handle_stderr(
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a SubagentStop hook message
|
||||
if line.contains("[SubagentStop Hook]") {
|
||||
if let Some(stop_data) = parse_subagent_stop_hook(&line) {
|
||||
tracing::debug!("Parsed SubagentStop hook: tool_use_id={:?}",
|
||||
stop_data.parent_tool_use_id);
|
||||
|
||||
// Emit agent-end event if we have a tool_use_id
|
||||
if let Some(tool_use_id) = stop_data.parent_tool_use_id {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
|
||||
let _ = app.emit(
|
||||
"claude:agent-end",
|
||||
AgentEndEvent {
|
||||
tool_use_id,
|
||||
ended_at: now,
|
||||
is_error: false,
|
||||
conversation_id: conversation_id.clone(),
|
||||
duration_ms: None,
|
||||
num_turns: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Still emit the stderr line as output
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
@@ -732,6 +760,30 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SubagentStopData {
|
||||
parent_tool_use_id: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> {
|
||||
// Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), ...
|
||||
|
||||
// Extract parent_tool_use_id if present
|
||||
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
|
||||
line.split("parent_tool_use_id=Some(\"")
|
||||
.nth(1)?
|
||||
.split('"')
|
||||
.next()
|
||||
.map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(SubagentStopData {
|
||||
parent_tool_use_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn process_json_line(
|
||||
line: &str,
|
||||
app: &AppHandle,
|
||||
@@ -901,7 +953,8 @@ fn process_json_line(
|
||||
}
|
||||
|
||||
// Emit agent-start event for Task tool invocations
|
||||
if name == "Task" {
|
||||
// Support both "Task" and "Task(agent_type)" syntax (CLI v2.1.33+)
|
||||
if name == "Task" || name.starts_with("Task(") {
|
||||
let description = input
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -936,6 +989,34 @@ fn process_json_line(
|
||||
);
|
||||
}
|
||||
|
||||
// Emit todo-update event for TodoWrite tool invocations
|
||||
if name == "TodoWrite" {
|
||||
if let Some(todos_value) = input.get("todos") {
|
||||
if let Some(todos_array) = todos_value.as_array() {
|
||||
let todos: Vec<TodoItem> = todos_array
|
||||
.iter()
|
||||
.filter_map(|todo| {
|
||||
serde_json::from_value(todo.clone()).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::debug!(
|
||||
"Emitting todo-update: {} todos, parent={:?}",
|
||||
todos.len(),
|
||||
parent_tool_use_id
|
||||
);
|
||||
|
||||
let _ = app.emit(
|
||||
"claude:todo-update",
|
||||
TodoUpdateEvent {
|
||||
todos,
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let desc = format_tool_description(name, input);
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
@@ -973,8 +1054,8 @@ fn process_json_line(
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "system".to_string(),
|
||||
content: format!("[Thinking] {}", thinking),
|
||||
line_type: "thinking".to_string(),
|
||||
content: thinking.clone(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: None,
|
||||
@@ -1496,7 +1577,7 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
|
||||
CharacterState::Coding
|
||||
} else if tool_name.starts_with("mcp__") {
|
||||
CharacterState::Mcp
|
||||
} else if tool_name == "Task" {
|
||||
} else if tool_name == "Task" || tool_name.starts_with("Task(") {
|
||||
CharacterState::Thinking
|
||||
} else {
|
||||
CharacterState::Typing
|
||||
@@ -1504,10 +1585,21 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
|
||||
}
|
||||
|
||||
fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
||||
// Helper function to check if a path is a memory file
|
||||
fn is_memory_path(path: &str) -> bool {
|
||||
path.contains("/.claude/") && (path.contains("/memory/") || path.ends_with("/MEMORY.md"))
|
||||
}
|
||||
|
||||
match name {
|
||||
"Read" => {
|
||||
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
|
||||
format!("Reading file: {}", path)
|
||||
if is_memory_path(path) {
|
||||
// Extract just the filename for cleaner display
|
||||
let filename = path.split('/').next_back().unwrap_or(path);
|
||||
format!("📝 Reading memory: {}", filename)
|
||||
} else {
|
||||
format!("Reading file: {}", path)
|
||||
}
|
||||
} else {
|
||||
"Reading file...".to_string()
|
||||
}
|
||||
@@ -1526,9 +1618,26 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
|
||||
"Searching in files...".to_string()
|
||||
}
|
||||
}
|
||||
"Edit" | "Write" => {
|
||||
"Edit" => {
|
||||
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
|
||||
format!("Editing: {}", path)
|
||||
if is_memory_path(path) {
|
||||
let filename = path.split('/').next_back().unwrap_or(path);
|
||||
format!("💾 Updating memory: {}", filename)
|
||||
} else {
|
||||
format!("Editing: {}", path)
|
||||
}
|
||||
} else {
|
||||
"Editing file...".to_string()
|
||||
}
|
||||
}
|
||||
"Write" => {
|
||||
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
|
||||
if is_memory_path(path) {
|
||||
let filename = path.split('/').next_back().unwrap_or(path);
|
||||
format!("💾 Writing memory: {}", filename)
|
||||
} else {
|
||||
format!("Editing: {}", path)
|
||||
}
|
||||
} else {
|
||||
"Editing file...".to_string()
|
||||
}
|
||||
@@ -1623,6 +1732,19 @@ mod tests {
|
||||
#[test]
|
||||
fn test_get_tool_state_task() {
|
||||
assert!(matches!(get_tool_state("Task"), CharacterState::Thinking));
|
||||
// Test CLI v2.1.33+ Task(agent_type) syntax
|
||||
assert!(matches!(
|
||||
get_tool_state("Task(Explore)"),
|
||||
CharacterState::Thinking
|
||||
));
|
||||
assert!(matches!(
|
||||
get_tool_state("Task(Plan)"),
|
||||
CharacterState::Thinking
|
||||
));
|
||||
assert!(matches!(
|
||||
get_tool_state("Task(general-purpose)"),
|
||||
CharacterState::Thinking
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1700,6 +1822,39 @@ mod tests {
|
||||
assert_eq!(desc, "Using tool: CustomTool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_memory_read() {
|
||||
let input =
|
||||
serde_json::json!({"file_path": "/home/user/.claude/projects/test/memory/MEMORY.md"});
|
||||
let desc = format_tool_description("Read", &input);
|
||||
assert_eq!(desc, "📝 Reading memory: MEMORY.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_memory_write() {
|
||||
let input = serde_json::json!(
|
||||
{"file_path": "/home/user/.claude/projects/test/memory/notes.md"}
|
||||
);
|
||||
let desc = format_tool_description("Write", &input);
|
||||
assert_eq!(desc, "💾 Writing memory: notes.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_memory_edit() {
|
||||
let input = serde_json::json!(
|
||||
{"file_path": "/home/user/.claude/projects/test/memory/patterns.md"}
|
||||
);
|
||||
let desc = format_tool_description("Edit", &input);
|
||||
assert_eq!(desc, "💾 Updating memory: patterns.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_tool_description_non_memory_read() {
|
||||
let input = serde_json::json!({"file_path": "/home/user/code/test.txt"});
|
||||
let desc = format_tool_description("Read", &input);
|
||||
assert_eq!(desc, "Reading file: /home/user/code/test.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wsl_bridge_new() {
|
||||
let bridge = WslBridge::new();
|
||||
@@ -1720,4 +1875,98 @@ mod tests {
|
||||
let manager = shared.lock();
|
||||
assert!(manager.get_active_conversations().is_empty());
|
||||
}
|
||||
|
||||
// SubagentStart hook parsing tests
|
||||
#[test]
|
||||
fn test_parse_subagent_start_hook_with_parent() {
|
||||
let line = r#"[SubagentStart Hook] agent_id=agent-abc123, parent_tool_use_id=Some("toolu_01XYZ789"), session_id=123"#;
|
||||
let result = parse_subagent_start_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.agent_id, "agent-abc123");
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_01XYZ789".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_start_hook_without_parent() {
|
||||
let line = r#"[SubagentStart Hook] agent_id=agent-xyz789, parent_tool_use_id=None, session_id=456"#;
|
||||
let result = parse_subagent_start_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.agent_id, "agent-xyz789");
|
||||
assert_eq!(data.parent_tool_use_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_start_hook_invalid() {
|
||||
let line = "[SubagentStart Hook] invalid data";
|
||||
let result = parse_subagent_start_hook(line);
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_start_hook_with_extra_fields() {
|
||||
let line = r#"[SubagentStart Hook] agent_id=agent-test, parent_tool_use_id=Some("toolu_test"), session_id=789, cwd=/home/user"#;
|
||||
let result = parse_subagent_start_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.agent_id, "agent-test");
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_test".to_string()));
|
||||
}
|
||||
|
||||
// SubagentStop hook parsing tests
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_with_parent() {
|
||||
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), session_id=123"#;
|
||||
let result = parse_subagent_stop_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_01ABC123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_without_parent() {
|
||||
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=None, session_id=456"#;
|
||||
let result = parse_subagent_stop_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.parent_tool_use_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_minimal() {
|
||||
let line = r#"[SubagentStop Hook] parent_tool_use_id=Some("toolu_minimal")"#;
|
||||
let result = parse_subagent_stop_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_minimal".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_with_extra_fields() {
|
||||
let line = r#"[SubagentStop Hook] stop_hook_active=false, parent_tool_use_id=Some("toolu_extra"), session_id=789, transcript_path=/path/to/transcript"#;
|
||||
let result = parse_subagent_stop_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_extra".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_empty() {
|
||||
let line = "[SubagentStop Hook]";
|
||||
let result = parse_subagent_stop_hook(line);
|
||||
|
||||
// Should still return Some with None parent_tool_use_id
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.parent_tool_use_id, None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user