Files
hikari-desktop/src/lib/components/Terminal.svelte
T
naomi edc863e020
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 5m26s
CI / Lint & Test (push) Successful in 20m11s
CI / Build Linux (push) Successful in 20m45s
CI / Build Windows (cross-compile) (push) Successful in 28m32s
feat: add copy buttons to user and assistant messages (#78)
Closes #74

Reviewed-on: #78
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-28 15:20:37 -08:00

358 lines
9.9 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 { searchState, searchQuery } from "$lib/stores/search";
import { clipboardStore } from "$lib/stores/clipboard";
import { shouldHidePaths, maskPaths } 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;
});
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";
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)}
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
{#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>
{/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-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>