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>
412 lines
12 KiB
Svelte
412 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
|
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
|
import ConversationTabs from "./ConversationTabs.svelte";
|
|
import Markdown from "./Markdown.svelte";
|
|
import HighlightedText from "./HighlightedText.svelte";
|
|
import ThinkingBlock from "./ThinkingBlock.svelte";
|
|
import { searchState, searchQuery } from "$lib/stores/search";
|
|
import { clipboardStore } from "$lib/stores/clipboard";
|
|
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
|
|
|
let terminalElement: HTMLDivElement;
|
|
let shouldAutoScroll = true;
|
|
let lines: TerminalLine[] = [];
|
|
let currentSearchQuery = "";
|
|
let currentConversationId: string | null = null;
|
|
let isRestoringScroll = false;
|
|
|
|
searchQuery.subscribe((value) => {
|
|
currentSearchQuery = value;
|
|
});
|
|
|
|
let hidePaths = false;
|
|
shouldHidePaths.subscribe((value) => {
|
|
hidePaths = value;
|
|
});
|
|
|
|
let showThinking = true;
|
|
showThinkingBlocks.subscribe((value) => {
|
|
showThinking = value;
|
|
});
|
|
|
|
claudeStore.terminalLines.subscribe((value) => {
|
|
lines = value;
|
|
});
|
|
|
|
claudeStore.activeConversationId.subscribe(async (newId) => {
|
|
if (!newId) return;
|
|
|
|
// Save current conversation's scroll position before switching
|
|
if (currentConversationId && currentConversationId !== newId && terminalElement) {
|
|
const position = shouldAutoScroll ? -1 : terminalElement.scrollTop;
|
|
claudeStore.saveScrollPosition(currentConversationId, position);
|
|
}
|
|
|
|
currentConversationId = newId;
|
|
|
|
// Restore scroll position for the new conversation after DOM updates
|
|
await tick();
|
|
if (terminalElement) {
|
|
const savedPosition = claudeStore.getScrollPosition(newId);
|
|
isRestoringScroll = true;
|
|
if (savedPosition === -1) {
|
|
// Auto-scroll to bottom
|
|
shouldAutoScroll = true;
|
|
terminalElement.scrollTop = terminalElement.scrollHeight;
|
|
} else {
|
|
// Restore to saved position
|
|
shouldAutoScroll = false;
|
|
terminalElement.scrollTop = savedPosition;
|
|
}
|
|
// Small delay to prevent the scroll handler from overriding our restore
|
|
setTimeout(() => {
|
|
isRestoringScroll = false;
|
|
}, 50);
|
|
}
|
|
});
|
|
|
|
function handleScroll() {
|
|
if (!terminalElement || isRestoringScroll) return;
|
|
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
|
|
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
|
|
}
|
|
|
|
afterUpdate(() => {
|
|
if (shouldAutoScroll && terminalElement && !isRestoringScroll) {
|
|
terminalElement.scrollTop = terminalElement.scrollHeight;
|
|
}
|
|
});
|
|
|
|
function getLineClass(type: string): string {
|
|
switch (type) {
|
|
case "user":
|
|
return "terminal-user";
|
|
case "assistant":
|
|
return "terminal-assistant";
|
|
case "system":
|
|
return "terminal-system italic";
|
|
case "tool":
|
|
return "terminal-tool";
|
|
case "error":
|
|
return "terminal-error";
|
|
case "thinking":
|
|
return "terminal-thinking";
|
|
default:
|
|
return "terminal-default";
|
|
}
|
|
}
|
|
|
|
function getLinePrefix(type: string): string {
|
|
switch (type) {
|
|
case "user":
|
|
return ">";
|
|
case "assistant":
|
|
return "";
|
|
case "system":
|
|
return "[system]";
|
|
case "tool":
|
|
return "[tool]";
|
|
case "error":
|
|
return "[error]";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function formatTime(date: Date): string {
|
|
return date.toLocaleTimeString("en-US", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
$: {
|
|
if (currentSearchQuery && lines.length > 0) {
|
|
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const regex = new RegExp(escapedQuery, "gi");
|
|
let totalMatches = 0;
|
|
for (const line of lines) {
|
|
const matches = line.content.match(regex);
|
|
if (matches) {
|
|
totalMatches += matches.length;
|
|
}
|
|
}
|
|
searchState.setMatchCount(totalMatches);
|
|
} else {
|
|
searchState.setMatchCount(0);
|
|
}
|
|
}
|
|
|
|
// Handle manual text selection copy events
|
|
function handleCopy() {
|
|
const selection = window.getSelection();
|
|
const selectedText = selection?.toString();
|
|
|
|
if (selectedText && selectedText.trim().length > 0) {
|
|
// Only capture multi-line or longer text (likely code/meaningful content)
|
|
if (selectedText.includes("\n") || selectedText.length > 50) {
|
|
clipboardStore.captureClipboard(selectedText, null, "Copied from chat");
|
|
}
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
// Listen for copy events on the terminal
|
|
if (terminalElement) {
|
|
terminalElement.addEventListener("copy", handleCopy);
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (terminalElement) {
|
|
terminalElement.removeEventListener("copy", handleCopy);
|
|
}
|
|
});
|
|
|
|
// Copy message content to clipboard
|
|
async function copyMessage(content: string) {
|
|
try {
|
|
await navigator.clipboard.writeText(content);
|
|
// Optionally capture to clipboard history
|
|
await clipboardStore.captureClipboard(content, null, "Message from chat");
|
|
|
|
// Visual feedback could be added here if needed
|
|
} catch (error) {
|
|
console.error("Failed to copy message:", error);
|
|
}
|
|
}
|
|
|
|
// State for showing "Copied!" feedback
|
|
let copiedMessageId: string | null = null;
|
|
|
|
async function handleCopyMessage(messageId: string, content: string) {
|
|
await copyMessage(content);
|
|
copiedMessageId = messageId;
|
|
setTimeout(() => {
|
|
copiedMessageId = null;
|
|
}, 2000);
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="terminal-container flex-1 overflow-hidden rounded-lg bg-[var(--bg-terminal)] border border-[var(--border-color)]"
|
|
>
|
|
<div
|
|
class="terminal-header flex items-center gap-2 px-4 py-2 border-b border-[var(--border-color)] bg-[var(--bg-secondary)]"
|
|
>
|
|
<div class="flex gap-1.5">
|
|
<div class="w-3 h-3 rounded-full bg-red-500"></div>
|
|
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
|
|
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
|
</div>
|
|
<span class="text-sm terminal-header-text ml-2">Terminal</span>
|
|
</div>
|
|
|
|
<ConversationTabs />
|
|
|
|
<div
|
|
bind:this={terminalElement}
|
|
onscroll={handleScroll}
|
|
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
|
|
style="font-size: var(--terminal-font-size, 14px);"
|
|
>
|
|
{#if lines.length === 0}
|
|
<div class="terminal-waiting italic">
|
|
Waiting for Claude... Type a message below to start!
|
|
</div>
|
|
{:else}
|
|
{#each lines as line (line.id)}
|
|
{#if line.type === "thinking"}
|
|
{#if showThinking}
|
|
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
|
{/if}
|
|
{:else}
|
|
<div
|
|
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
|
style={line.parentToolUseId
|
|
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
|
: ""}
|
|
>
|
|
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
|
{#if line.parentToolUseId}
|
|
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
|
|
<svg
|
|
class="inline-block w-3 h-3 -mt-0.5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 5l7 7-7 7"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
{/if}
|
|
{#if line.cost && line.cost.costUsd > 0}
|
|
<span
|
|
class="terminal-cost text-xs mr-2"
|
|
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
|
|
>
|
|
${line.cost.costUsd < 0.01
|
|
? line.cost.costUsd.toFixed(4)
|
|
: line.cost.costUsd.toFixed(3)}
|
|
</span>
|
|
{/if}
|
|
{#if getLinePrefix(line.type)}
|
|
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
|
{/if}
|
|
{#if line.toolName}
|
|
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
|
{/if}
|
|
{#if line.type === "assistant" || line.type === "user"}
|
|
<div class="message-content-wrapper">
|
|
<Markdown
|
|
content={maskPaths(line.content, hidePaths)}
|
|
searchQuery={currentSearchQuery}
|
|
/>
|
|
<button
|
|
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
|
|
onclick={() => handleCopyMessage(line.id, line.content)}
|
|
title="Copy message"
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
</svg>
|
|
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<HighlightedText
|
|
content={maskPaths(line.content, hidePaths)}
|
|
searchQuery={currentSearchQuery}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.terminal-content {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: var(--border-color) var(--bg-terminal);
|
|
}
|
|
|
|
/* Terminal text colors that adapt to theme */
|
|
.terminal-user {
|
|
color: var(--terminal-user, #22d3ee);
|
|
}
|
|
|
|
.terminal-assistant {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.terminal-system {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.terminal-tool {
|
|
color: var(--terminal-tool, #c084fc);
|
|
}
|
|
|
|
.terminal-error {
|
|
color: var(--terminal-error, #f87171);
|
|
}
|
|
|
|
.terminal-default {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.terminal-timestamp {
|
|
color: var(--text-tertiary, #6b7280);
|
|
}
|
|
|
|
.terminal-cost {
|
|
color: var(--terminal-cost, #10b981);
|
|
background: var(--terminal-cost-bg, rgba(16, 185, 129, 0.1));
|
|
padding: 0 4px;
|
|
border-radius: 3px;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.terminal-prefix {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.terminal-tool-name {
|
|
color: var(--terminal-tool-name, #ddd6fe);
|
|
}
|
|
|
|
.terminal-waiting {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.terminal-header-text {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
:global(.search-highlight) {
|
|
background-color: var(--search-highlight, #fbbf24);
|
|
color: var(--search-highlight-text, #000);
|
|
border-radius: 2px;
|
|
padding: 0 2px;
|
|
}
|
|
|
|
/* Message content wrapper for positioning */
|
|
.message-content-wrapper {
|
|
position: relative;
|
|
display: inline-block;
|
|
width: 100%;
|
|
}
|
|
|
|
/* Copy button styling */
|
|
.copy-message-btn {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4em;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-secondary);
|
|
padding: 0.25em 0.5em;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
font-size: 0.85em;
|
|
font-family: inherit;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.copy-message-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
border-color: var(--border-hover, var(--border-color));
|
|
}
|
|
|
|
.copy-message-btn svg {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Ensure relative positioning for parent */
|
|
.terminal-line {
|
|
position: relative;
|
|
}
|
|
</style>
|