generated from nhcarrigan/template
e6e9f7ae59
## Summary A large productivity-focused feature branch delivering a suite of improvements across automation, project management, theming, performance, and documentation. ### Features - **Guided Project Workflow** (#189) — Four-phase workflow panel (Discuss → Plan → Execute → Verify) to keep projects structured from idea to completion - **Automated Task Loop** (#179) — Per-task conversation orchestration with wave-based parallel execution, blocked-task detection, and concurrency control - **Wave-Based Parallel Execution** (#191) — Tasks run in dependency-aware waves with configurable concurrency; independent tasks execute in parallel - **Auto-Commit After Task Completion** (#192) — Task Loop optionally commits after each completed task so progress is never lost - **PRD Creator** (#180) — AI-assisted PRD and task list panel that outputs `hikari-tasks.json` for the Task Loop to consume - **Project Context Panel** (#188) — Persistent `PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, and `STATE.md` files injected into Claude's context automatically - **Codebase Mapper** (#190) — Generates a `CODEBASE.md` architectural summary so Claude always understands the project structure - **Community Preset Themes** (#181) — Six built-in community themes: Dracula, Catppuccin Mocha, Nord, Solarized Dark, Gruvbox Dark, and Rosé Pine - **In-App Changelog Panel** (#193) — Fetches release notes from GitHub at runtime and displays them inside the app - **Full Embedded Documentation** (#196) — Replaced the single-page help modal with a 12-page paginated docs browser featuring a sidebar TOC, prev/next navigation, keyboard navigation (arrow keys, `?` shortcut), and comprehensive coverage of every feature ### Performance & Fixes - **Lazy Loading & Virtualisation** (#194) — Virtual windowing for conversation history, markdown memoisation, and debounced search for smooth rendering of large sessions - **Ctrl+C Copy Fix** (#195) — `Ctrl+C` now copies selected text as expected; interrupt-Claude behaviour only fires when no text is selected ### UX - Back-to-workflow button in PRD Creator and Task Loop panels for easy navigation - Navigation icon cluster replaced with a single clean dropdown menu ## Closes Closes #179 Closes #180 Closes #181 Closes #188 Closes #189 Closes #190 Closes #191 Closes #192 Closes #193 Closes #194 Closes #195 Closes #196 --- ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #197 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
569 lines
18 KiB
Svelte
569 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
|
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
|
import { get } from "svelte/store";
|
|
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 ToolCallBlock from "./ToolCallBlock.svelte";
|
|
import { searchState, searchQuery } from "$lib/stores/search";
|
|
import { clipboardStore } from "$lib/stores/clipboard";
|
|
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
|
|
|
// Virtual windowing constants — keeps the DOM lean during long sessions
|
|
const WINDOW_SIZE = 150; // max lines rendered at once
|
|
const LOAD_CHUNK = 50; // how many older lines to load when scrolling up
|
|
const AVG_LINE_HEIGHT = 60; // rough px estimate per line, used for top spacer
|
|
|
|
let terminalElement: HTMLDivElement;
|
|
let shouldAutoScroll = true;
|
|
let lines: TerminalLine[] = [];
|
|
let currentSearchQuery = "";
|
|
let currentConversationId: string | null = null;
|
|
let isRestoringScroll = false;
|
|
let windowStart = 0;
|
|
let isLoadingMore = false;
|
|
let isSwitchingConversation = false;
|
|
|
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
searchQuery.subscribe((value) => {
|
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
|
searchDebounceTimer = setTimeout(() => {
|
|
currentSearchQuery = value;
|
|
}, 150);
|
|
});
|
|
|
|
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;
|
|
|
|
// Guard the $: reactive auto-scroll block from firing with stale `lines`
|
|
// (the old conversation's data) during the switch. Without this, Svelte's
|
|
// reactive system can re-run the window-advance block before `terminalLines`
|
|
// has recomputed for the new conversation, overriding our correct windowStart.
|
|
isSwitchingConversation = true;
|
|
|
|
// Read the new conversation's lines directly from the store — the derived
|
|
// `terminalLines` store (and thus `lines`) may not have recomputed yet when
|
|
// this subscriber fires, so using `lines` here would give stale data.
|
|
const newConvLines = get(claudeStore.conversations).get(newId)?.terminalLines ?? [];
|
|
const savedPosition = claudeStore.getScrollPosition(newId);
|
|
if (savedPosition === -1) {
|
|
// Will auto-scroll: pin the window to the tail of the new conversation
|
|
shouldAutoScroll = true;
|
|
windowStart = Math.max(0, newConvLines.length - WINDOW_SIZE);
|
|
} else {
|
|
// Will restore a specific position: always start from the top of history
|
|
shouldAutoScroll = false;
|
|
windowStart = 0;
|
|
}
|
|
|
|
// Block the scroll handler during the entire DOM transition — scroll events
|
|
// can fire mid-tick when the content changes, and handleScroll would see
|
|
// scrollTop not at the bottom yet and set shouldAutoScroll = false, breaking
|
|
// autoscroll for the new conversation permanently.
|
|
isRestoringScroll = true;
|
|
|
|
// Restore scroll position for the new conversation after DOM updates.
|
|
// Clear the switch guard first so the $: block can react to new lines
|
|
// arriving after the switch settles.
|
|
await tick();
|
|
isSwitchingConversation = false;
|
|
if (terminalElement) {
|
|
if (savedPosition === -1) {
|
|
terminalElement.scrollTop = terminalElement.scrollHeight;
|
|
} else {
|
|
terminalElement.scrollTop = savedPosition;
|
|
}
|
|
// Small delay to prevent the scroll handler from overriding our restore
|
|
setTimeout(() => {
|
|
isRestoringScroll = false;
|
|
}, 50);
|
|
}
|
|
});
|
|
|
|
async function handleScroll() {
|
|
if (!terminalElement || isRestoringScroll) return;
|
|
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
|
|
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
|
|
|
|
// Load older lines when the user scrolls near the top of the visible window.
|
|
// Use windowStart * AVG_LINE_HEIGHT (the spacer height) as the baseline so
|
|
// we trigger at the top of the rendered content, not the absolute container top.
|
|
if (scrollTop < windowStart * AVG_LINE_HEIGHT + 300 && windowStart > 0 && !isLoadingMore) {
|
|
isLoadingMore = true;
|
|
const prevScrollHeight = terminalElement.scrollHeight;
|
|
const prevScrollTop = terminalElement.scrollTop;
|
|
|
|
windowStart = Math.max(0, windowStart - LOAD_CHUNK);
|
|
|
|
await tick();
|
|
|
|
if (terminalElement) {
|
|
// Compensate for the new items pushing content down
|
|
terminalElement.scrollTop =
|
|
prevScrollTop + (terminalElement.scrollHeight - prevScrollHeight);
|
|
}
|
|
isLoadingMore = false;
|
|
}
|
|
}
|
|
|
|
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";
|
|
case "config-change":
|
|
return "terminal-config-change";
|
|
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]";
|
|
case "config-change":
|
|
return "[config]";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function formatTime(date: Date): string {
|
|
return date.toLocaleTimeString("en-US", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
// Visible slice — only render lines within the current window
|
|
$: visibleLines = lines.slice(windowStart, windowStart + WINDOW_SIZE);
|
|
|
|
// Height of the invisible spacer above the visible window
|
|
$: topSpacerHeight = windowStart * AVG_LINE_HEIGHT;
|
|
|
|
// Advance the window forward when auto-scrolling and new lines overflow it.
|
|
// Skip during conversation switches — `lines` may still hold the previous
|
|
// conversation's data, which would push windowStart past the new conv's end.
|
|
$: if (shouldAutoScroll && !isSwitchingConversation && lines.length > windowStart + WINDOW_SIZE) {
|
|
windowStart = Math.max(0, lines.length - WINDOW_SIZE);
|
|
}
|
|
|
|
$: {
|
|
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);
|
|
}
|
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
|
});
|
|
|
|
// 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" });
|
|
}
|
|
</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); font-family: var(--terminal-font-family, monospace);"
|
|
>
|
|
{#if lines.length === 0}
|
|
<div class="terminal-waiting italic">
|
|
Waiting for Claude... Type a message below to start!
|
|
</div>
|
|
{:else}
|
|
<div style="height: {topSpacerHeight}px" aria-hidden="true"></div>
|
|
{#if windowStart > 0}
|
|
<div class="terminal-older-indicator">
|
|
↑ {windowStart} older {windowStart === 1 ? "message" : "messages"} — scroll up to load
|
|
</div>
|
|
{/if}
|
|
{#each visibleLines as line (line.id)}
|
|
{#if line.type === "thinking"}
|
|
{#if showThinking}
|
|
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
|
{/if}
|
|
{:else if line.type === "tool"}
|
|
<div
|
|
style={line.parentToolUseId
|
|
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
|
: ""}
|
|
>
|
|
<ToolCallBlock
|
|
toolName={line.toolName ?? null}
|
|
content={maskPaths(line.content, hidePaths)}
|
|
timestamp={line.timestamp}
|
|
/>
|
|
</div>
|
|
{: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.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}
|
|
<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);
|
|
}
|
|
|
|
.terminal-config-change {
|
|
color: var(--terminal-config-change, #a78bfa);
|
|
}
|
|
|
|
.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-older-indicator {
|
|
color: var(--text-tertiary, #6b7280);
|
|
font-size: 0.75rem;
|
|
text-align: center;
|
|
padding: 0.25rem 0;
|
|
margin-bottom: 0.5rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.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>
|