generated from nhcarrigan/template
54280b3920
Closes #152, closes #150 - Add use_worktree bool to ClaudeStartOptions and HikariConfig (Rust + TS) - Pass --worktree flag to claude in both WSL and non-WSL command paths - Detect WorktreeCreate/WorktreeRemove hook events in stderr handler - Emit worktree events with distinct line_type instead of error - Add worktree line type to TerminalLine union, Terminal rendering, and tests - Display worktree events in green with [worktree] prefix - Add worktree isolation toggle to Agent Settings section of ConfigSidebar - Thread use_worktree through all seven start_claude call sites
521 lines
15 KiB
Svelte
521 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
|
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
|
|
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";
|
|
case "rate-limit":
|
|
return "terminal-rate-limit";
|
|
case "compact-prompt":
|
|
return "terminal-compact-prompt";
|
|
case "worktree":
|
|
return "terminal-worktree";
|
|
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]";
|
|
case "rate-limit":
|
|
return "[rate-limit]";
|
|
case "worktree":
|
|
return "[worktree]";
|
|
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);
|
|
}
|
|
|
|
async function handleCompact() {
|
|
if (!currentConversationId) return;
|
|
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
|
|
}
|
|
|
|
// Collapsible tool lines
|
|
const TOOL_COLLAPSE_THRESHOLD = 60;
|
|
let expandedToolLines: Record<string, boolean> = {};
|
|
|
|
function isToolContentLong(content: string): boolean {
|
|
return content.length > TOOL_COLLAPSE_THRESHOLD;
|
|
}
|
|
|
|
function truncateToolContent(content: string): string {
|
|
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
|
}
|
|
|
|
function toggleToolLine(id: string) {
|
|
expandedToolLines = { ...expandedToolLines, [id]: !expandedToolLines[id] };
|
|
}
|
|
</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 === "compact-prompt"}
|
|
<button class="compact-action-btn" onclick={handleCompact}>
|
|
⚡ Compact Conversation
|
|
</button>
|
|
{:else 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 if line.type === "tool" && isToolContentLong(maskPaths(line.content, hidePaths))}
|
|
<span class="tool-collapsible">
|
|
<HighlightedText
|
|
content={expandedToolLines[line.id]
|
|
? maskPaths(line.content, hidePaths)
|
|
: truncateToolContent(maskPaths(line.content, hidePaths))}
|
|
searchQuery={currentSearchQuery}
|
|
/>
|
|
<button
|
|
class="tool-toggle-btn"
|
|
onclick={() => toggleToolLine(line.id)}
|
|
title={expandedToolLines[line.id] ? "Collapse" : "Expand to see full content"}
|
|
>
|
|
{expandedToolLines[line.id] ? "▲" : "▼"}
|
|
</button>
|
|
</span>
|
|
{: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-rate-limit {
|
|
color: var(--terminal-rate-limit, #fb923c);
|
|
}
|
|
|
|
.terminal-compact-prompt {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.terminal-worktree {
|
|
color: var(--terminal-worktree, #34d399);
|
|
}
|
|
|
|
.compact-action-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4em;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--terminal-error, #f87171);
|
|
color: var(--terminal-error, #f87171);
|
|
padding: 0.3em 0.8em;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
font-size: 0.9em;
|
|
font-family: inherit;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.compact-action-btn:hover {
|
|
background: color-mix(in srgb, var(--terminal-error, #f87171) 15%, transparent);
|
|
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;
|
|
}
|
|
|
|
.tool-collapsible {
|
|
display: inline-flex;
|
|
align-items: baseline;
|
|
gap: 0.4em;
|
|
}
|
|
|
|
.tool-toggle-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-tertiary, #6b7280);
|
|
cursor: pointer;
|
|
font-size: 0.7em;
|
|
padding: 0;
|
|
line-height: 1;
|
|
opacity: 0.7;
|
|
transition: opacity 0.15s ease;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.tool-toggle-btn:hover {
|
|
opacity: 1;
|
|
color: var(--terminal-tool, #c084fc);
|
|
}
|
|
</style>
|