feat: add multiple productivity features and UI enhancements (#68)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 54s
CI / Lint & Test (push) Successful in 14m42s
CI / Build Linux (push) Successful in 19m4s
CI / Build Windows (cross-compile) (push) Successful in 28m37s

## Summary

This PR adds a collection of productivity features and UI enhancements to improve the Hikari Desktop experience:

### New Features
- **Clipboard History** (#25) - Track and manage copied code snippets with language detection, search, filtering, and pinning
- **Quick Actions Panel** (#15) - Buttons for common quick actions like "Review PR", "Run tests", "Explain file", with customizable actions
- **Git Integration Panel** (#24) - View current branch, changed/staged files, quick git actions (commit, push, pull), and branch management
- **Session Import/Export** (#8) - Export conversations to JSON and import previously saved sessions
- **Snippet Library** (#22) - Save and reuse common prompts with categories and quick insert
- **Session History** (#14) - Auto-save conversations with browsable history and search
- **High Contrast Mode** (#20) - Accessibility theme with improved visibility
- **Minimize to System Tray** (#11) - System tray support with right-click menu

### UI Enhancements
- Trans-pride gradient theme applied across UI elements
- Copy button added to code blocks
- Linter formatting and eslint-disable comments for cleaner code

## Closes

Closes #8
Closes #11
Closes #14
Closes #15
Closes #20
Closes #22
Closes #24
Closes #25
Closes #34
Closes #35
Closes #36
Closes #37
Closes #69
Closes #70

## Test Plan

- [ ] Verify clipboard history captures code from code block copy buttons
- [ ] Verify clipboard history captures manually selected text from terminal
- [ ] Test snippet library CRUD operations and insertion
- [ ] Test quick actions panel with default and custom actions
- [ ] Test git panel shows correct status, branch, and performs git operations
- [ ] Test session history auto-save and restore
- [ ] Test session import/export roundtrip
- [ ] Verify high contrast mode provides adequate contrast
- [ ] Test minimize to tray functionality and tray menu
- [ ] Verify trans-pride gradient theme displays correctly in all themes

---
* This PR was created with help from Hikari~ 🌸*

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #68
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #68.
This commit is contained in:
2026-01-25 22:19:00 -08:00
committed by Naomi Carrigan
parent 852a4d6661
commit 4c46d4c8fd
47 changed files with 11695 additions and 319 deletions
+6 -54
View File
@@ -34,35 +34,12 @@
return "animate-idle";
}
}
function getBackgroundGlow(): string {
switch (currentState) {
case "thinking":
return "shadow-thinking";
case "typing":
return "shadow-typing";
case "searching":
return "shadow-searching";
case "coding":
return "shadow-coding";
case "mcp":
return "shadow-mcp";
case "success":
return "shadow-success";
case "error":
return "shadow-error";
default:
return "";
}
}
</script>
<div
class="anime-girl-container flex flex-col items-center justify-between h-full p-4 overflow-hidden"
>
<div
class="character-frame relative {getBackgroundGlow()} flex-1 flex items-center justify-center min-h-0"
>
<div class="character-frame relative flex-1 flex items-center justify-center min-h-0">
<div class="sprite-container {getAnimationClass()} h-full flex items-center justify-center">
<img
src="/sprites/{info.spriteFile}"
@@ -97,37 +74,12 @@
</div>
<style>
.anime-girl-container {
transition: all 0.3s ease;
}
.character-frame {
border-radius: 50%;
transition: box-shadow 0.3s ease;
}
.shadow-thinking {
box-shadow: 0 0 30px rgba(147, 51, 234, 0.5);
}
.shadow-typing {
box-shadow: 0 0 30px rgba(59, 130, 246, 0.5);
}
.shadow-searching {
box-shadow: 0 0 30px rgba(234, 179, 8, 0.5);
}
.shadow-coding {
box-shadow: 0 0 30px rgba(34, 197, 94, 0.5);
}
.shadow-mcp {
box-shadow: 0 0 30px rgba(236, 72, 153, 0.5);
}
.shadow-success {
box-shadow: 0 0 30px rgba(16, 185, 129, 0.5);
}
.shadow-error {
box-shadow: 0 0 30px rgba(239, 68, 68, 0.5);
transition: all 0.3s ease;
}
@keyframes idle-bob {
@@ -0,0 +1,497 @@
<script lang="ts">
import { onMount } from "svelte";
import { clipboardStore, type ClipboardEntry } from "$lib/stores/clipboard";
export let isOpen = false;
export let onClose: () => void;
export let onInsert: (content: string) => void = () => {};
let searchQuery = "";
let selectedLanguage: string | null = null;
let confirmingDeleteId: string | null = null;
let copiedId: string | null = null;
// Subscribe to derived stores
const filteredEntries = clipboardStore.filteredEntries;
const languagesStore = clipboardStore.languages;
const isLoadingStore = clipboardStore.isLoading;
$: entries = $filteredEntries;
$: languages = $languagesStore;
$: isLoading = $isLoadingStore;
onMount(() => {
if (isOpen) {
clipboardStore.loadEntries();
}
});
$: if (isOpen) {
clipboardStore.loadEntries();
}
function handleSearch() {
clipboardStore.setSearchQuery(searchQuery);
}
function handleLanguageFilter(lang: string | null) {
selectedLanguage = lang;
clipboardStore.setLanguageFilter(lang);
}
async function handleCopy(entry: ClipboardEntry) {
const success = await clipboardStore.copyToClipboard(entry.content);
if (success) {
copiedId = entry.id;
setTimeout(() => {
copiedId = null;
}, 2000);
}
}
function handleInsert(entry: ClipboardEntry) {
onInsert(entry.content);
onClose();
}
async function handleDelete(id: string) {
await clipboardStore.deleteEntry(id);
confirmingDeleteId = null;
}
async function handleTogglePin(id: string) {
await clipboardStore.togglePin(id);
}
async function handleClearHistory() {
if (confirm("Clear all non-pinned clipboard entries?")) {
await clipboardStore.clearHistory();
}
}
function truncateContent(content: string, maxLength: number = 200): string {
if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + "...";
}
function getLanguageIcon(language: string | null): string {
const icons: Record<string, string> = {
typescript: "TS",
javascript: "JS",
python: "PY",
rust: "RS",
go: "GO",
java: "JV",
c: "C",
cpp: "C++",
csharp: "C#",
php: "PHP",
ruby: "RB",
swift: "SW",
kotlin: "KT",
sql: "SQL",
html: "HTML",
css: "CSS",
json: "JSON",
yaml: "YAML",
bash: "SH",
shell: "SH",
};
return language ? icons[language.toLowerCase()] || language.toUpperCase().slice(0, 3) : "TXT";
}
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="clipboard-overlay" on:click={onClose}>
<div class="clipboard-panel" on:click|stopPropagation>
<div class="clipboard-header">
<h2>📋 Clipboard History</h2>
<div class="header-actions">
{#if entries.length > 0}
<button
class="clear-btn"
on:click={handleClearHistory}
title="Clear non-pinned entries"
>
🗑️ Clear
</button>
{/if}
<button class="close-btn" on:click={onClose}>✕</button>
</div>
</div>
<div class="clipboard-controls">
<div class="search-box">
<input
type="text"
placeholder="Search clipboard..."
bind:value={searchQuery}
on:input={handleSearch}
/>
</div>
<div class="language-filter">
<button
class="filter-btn"
class:active={selectedLanguage === null}
on:click={() => handleLanguageFilter(null)}
>
All
</button>
{#each languages as lang (lang)}
<button
class="filter-btn"
class:active={selectedLanguage === lang}
on:click={() => handleLanguageFilter(lang)}
>
{getLanguageIcon(lang)}
</button>
{/each}
</div>
</div>
<div class="clipboard-content">
{#if isLoading}
<div class="loading">Loading...</div>
{:else if entries.length === 0}
<div class="empty-state">
<p>📭 No clipboard entries yet</p>
<p class="hint">
Copy code from Claude's responses or use the copy button on code blocks to save them
here.
</p>
</div>
{:else}
<div class="entries-list">
{#each entries as entry (entry.id)}
<div class="entry" class:pinned={entry.is_pinned}>
<div class="entry-header">
<div class="entry-meta">
<span class="language-badge">{getLanguageIcon(entry.language)}</span>
<span class="timestamp">{clipboardStore.formatTimestamp(entry.timestamp)}</span>
{#if entry.is_pinned}
<span class="pin-badge">📌</span>
{/if}
</div>
<div class="entry-actions">
<button
class="action-btn"
title={entry.is_pinned ? "Unpin" : "Pin"}
on:click={() => handleTogglePin(entry.id)}
>
{entry.is_pinned ? "📌" : "📍"}
</button>
<button
class="action-btn"
title="Copy to clipboard"
on:click={() => handleCopy(entry)}
>
{copiedId === entry.id ? "✓" : "📋"}
</button>
<button
class="action-btn insert-btn"
title="Insert"
on:click={() => handleInsert(entry)}
>
➡️
</button>
{#if confirmingDeleteId === entry.id}
<button
class="action-btn confirm-delete"
on:click={() => handleDelete(entry.id)}
>
</button>
<button class="action-btn" on:click={() => (confirmingDeleteId = null)}>
</button>
{:else}
<button
class="action-btn delete-btn"
title="Delete"
on:click={() => (confirmingDeleteId = entry.id)}
>
🗑️
</button>
{/if}
</div>
</div>
<pre class="entry-content"><code>{truncateContent(entry.content)}</code></pre>
{#if entry.source}
<div class="entry-source">From: {entry.source}</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.clipboard-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.clipboard-panel {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 90%;
max-width: 700px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.clipboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.clipboard-header h2 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.clear-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.clear-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.close-btn {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
}
.close-btn:hover {
color: var(--text-primary);
}
.clipboard-controls {
padding: 12px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 12px;
}
.search-box input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
}
.search-box input:focus {
outline: none;
border-color: var(--trans-pink);
box-shadow: 0 0 0 2px rgba(245, 169, 184, 0.2);
}
.language-filter {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.filter-btn {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
}
.filter-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--trans-gradient-vibrant);
color: #1a1a2e;
border-color: transparent;
}
.clipboard-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.loading,
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-secondary);
}
.empty-state p {
margin: 8px 0;
}
.empty-state .hint {
font-size: 13px;
opacity: 0.7;
}
.entries-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.entry {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px;
transition: border-color 0.2s;
}
.entry:hover {
border-color: var(--trans-pink);
}
.entry.pinned {
border-color: var(--trans-blue);
background: linear-gradient(
135deg,
rgba(91, 206, 250, 0.05) 0%,
rgba(245, 169, 184, 0.05) 100%
);
}
.entry-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.entry-meta {
display: flex;
align-items: center;
gap: 8px;
}
.language-badge {
background: var(--bg-tertiary);
color: var(--text-secondary);
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
font-family: monospace;
}
.timestamp {
color: var(--text-tertiary);
font-size: 11px;
}
.pin-badge {
font-size: 12px;
}
.entry-actions {
display: flex;
gap: 4px;
}
.action-btn {
background: transparent;
border: none;
padding: 4px 6px;
cursor: pointer;
font-size: 14px;
opacity: 0.6;
transition: opacity 0.2s;
}
.action-btn:hover {
opacity: 1;
}
.insert-btn {
background: var(--trans-gradient-vibrant);
border-radius: 4px;
opacity: 1;
}
.delete-btn:hover {
color: #ff6b6b;
}
.confirm-delete {
color: #ff6b6b;
opacity: 1;
}
.entry-content {
margin: 0;
padding: 10px;
background: var(--bg-primary);
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.entry-content code {
font-family: "JetBrains Mono", "Fira Code", monospace;
color: var(--text-primary);
}
.entry-source {
margin-top: 8px;
font-size: 11px;
color: var(--text-tertiary);
font-style: italic;
}
</style>
+563
View File
@@ -0,0 +1,563 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState, characterInfo } from "$lib/stores/character";
import { isStreamerMode } from "$lib/stores/config";
import { handleNewUserMessage } from "$lib/notifications/rules";
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
interface Props {
onExpand: () => void;
}
let { onExpand }: Props = $props();
let inputValue = $state("");
let isSubmitting = $state(false);
let isConnected = $state(false);
let isProcessing = $state(false);
let streamerModeActive = $state(false);
let currentState: CharacterState = $state("idle");
let info: CharacterStateInfo = $state({
state: "idle",
label: "Ready",
description: "Waiting for your command~",
spriteFile: "idle.png",
});
// Recent messages for compact display
let recentMessages = $state<Array<{ type: string; content: string }>>([]);
const MAX_RECENT_MESSAGES = 3;
claudeStore.connectionStatus.subscribe((status) => {
isConnected = status === "connected";
});
isClaudeProcessing.subscribe((processing) => {
isProcessing = processing;
});
isStreamerMode.subscribe((value) => {
streamerModeActive = value;
});
characterState.subscribe((state) => {
currentState = state;
});
characterInfo.subscribe((i) => {
info = i;
});
// Track recent terminal output
claudeStore.terminalLines.subscribe((lines) => {
const recent = lines.slice(-MAX_RECENT_MESSAGES).map((line) => ({
type: line.type,
content: line.content.substring(0, 100) + (line.content.length > 100 ? "..." : ""),
}));
recentMessages = recent;
});
function getAnimationClass(): string {
switch (currentState) {
case "thinking":
return "animate-thinking";
case "typing":
return "animate-typing";
case "searching":
return "animate-searching";
case "success":
return "animate-celebrate";
case "error":
return "animate-shake";
default:
return "animate-idle";
}
}
function getStateGlow(): string {
switch (currentState) {
case "thinking":
return "glow-thinking";
case "typing":
return "glow-typing";
case "success":
return "glow-success";
case "error":
return "glow-error";
default:
return "";
}
}
async function handleSubmit(event: Event) {
event.preventDefault();
const message = inputValue.trim();
if (!message || isSubmitting || !isConnected) return;
isSubmitting = true;
inputValue = "";
handleNewUserMessage();
claudeStore.addLine("user", message);
characterState.setState("thinking");
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("send_prompt", {
conversationId,
message,
});
} catch (error) {
console.error("Failed to send prompt:", error);
claudeStore.addLine("error", `Failed to send: ${error}`);
characterState.setTemporaryState("error", 3000);
} finally {
isSubmitting = false;
}
}
async function handleInterrupt() {
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Interrupted");
characterState.setState("idle");
} catch (error) {
console.error("Failed to interrupt:", error);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event);
}
// Escape expands to full mode
if (event.key === "Escape") {
onExpand();
}
}
</script>
<div class="compact-container {getStateGlow()}">
<!-- Character sprite (smaller) -->
<div class="compact-character">
<div class="sprite-wrapper {getAnimationClass()}">
<img
src="/sprites/{info.spriteFile}"
alt="Hikari - {info.label}"
class="compact-sprite"
onerror={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.src = "/sprites/placeholder.svg";
}}
/>
</div>
<div class="state-badge">{info.label}</div>
</div>
<!-- Recent message preview -->
<div class="message-preview">
{#if recentMessages.length > 0}
{#each recentMessages.slice(-1) as msg (msg.content)}
<div class="preview-message {msg.type}">
{msg.content}
</div>
{/each}
{:else}
<div class="preview-message system">Ask me anything~</div>
{/if}
</div>
<!-- Compact input -->
<form onsubmit={handleSubmit} class="compact-input-form">
<input
type="text"
bind:value={inputValue}
onkeydown={handleKeyDown}
placeholder={isConnected ? "Quick message..." : "Not connected"}
disabled={isSubmitting || !isConnected}
class="compact-input"
/>
<div class="compact-buttons">
{#if isProcessing}
<button type="button" onclick={handleInterrupt} class="compact-btn stop-btn" title="Stop">
</button>
{:else}
<button
type="submit"
disabled={!isConnected || isSubmitting || !inputValue.trim()}
class="compact-btn send-btn"
title="Send"
>
</button>
{/if}
<button type="button" onclick={onExpand} class="compact-btn expand-btn" title="Expand (Esc)">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="15 3 21 3 21 9"></polyline>
<polyline points="9 21 3 21 3 15"></polyline>
<line x1="21" y1="3" x2="14" y2="10"></line>
<line x1="3" y1="21" x2="10" y2="14"></line>
</svg>
</button>
</div>
</form>
<!-- Streamer mode indicator -->
{#if streamerModeActive}
<div class="compact-live-indicator" title="Streamer mode active"></div>
{/if}
</div>
<style>
.compact-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
padding: 12px;
gap: 8px;
position: relative;
transition: all 0.3s ease;
}
.compact-container::before {
content: "";
position: absolute;
inset: 0;
padding: 2px;
background: transparent;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: none;
border-radius: 8px;
}
.glow-thinking {
box-shadow: inset 0 0 30px rgba(147, 51, 234, 0.15);
}
.glow-thinking::before {
background: linear-gradient(180deg, #9333ea, var(--trans-blue));
opacity: 0.6;
}
.glow-typing {
box-shadow: inset 0 0 30px rgba(59, 130, 246, 0.15);
}
.glow-typing::before {
background: linear-gradient(180deg, #3b82f6, var(--trans-pink));
opacity: 0.6;
}
.glow-success {
box-shadow: inset 0 0 30px rgba(16, 185, 129, 0.15);
}
.glow-success::before {
background: linear-gradient(180deg, #10b981, var(--trans-blue));
opacity: 0.6;
}
.glow-error {
box-shadow: inset 0 0 30px rgba(239, 68, 68, 0.15);
}
.glow-error::before {
background: linear-gradient(180deg, #ef4444, var(--trans-pink));
opacity: 0.6;
}
.compact-character {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-height: 0;
gap: 4px;
}
.sprite-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.compact-sprite {
max-height: 100%;
max-width: 100%;
object-fit: contain;
}
.state-badge {
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 600;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--accent-primary);
}
.message-preview {
min-height: 24px;
max-height: 48px;
overflow: hidden;
}
.preview-message {
font-size: 11px;
line-height: 1.3;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
padding: 4px 8px;
background: var(--bg-tertiary);
border-radius: 6px;
}
.preview-message.user {
color: var(--trans-pink);
}
.preview-message.assistant {
color: var(--trans-blue);
}
.preview-message.error {
color: #ef4444;
}
.compact-input-form {
display: flex;
gap: 6px;
align-items: center;
}
.compact-input {
flex: 1;
padding: 8px 12px;
font-size: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
outline: none;
transition: all 0.2s;
}
.compact-input:focus {
border-color: var(--trans-blue);
box-shadow: 0 0 0 2px rgba(91, 206, 250, 0.2);
}
.compact-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.compact-input::placeholder {
color: var(--text-tertiary);
}
.compact-buttons {
display: flex;
gap: 4px;
}
.compact-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
font-weight: bold;
}
.compact-btn:hover:not(:disabled) {
background: var(--bg-tertiary);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.compact-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.send-btn:not(:disabled) {
background: var(--trans-gradient-vibrant);
border-color: transparent;
color: #1a1a2e;
}
.stop-btn {
background: rgba(239, 68, 68, 0.2);
border-color: rgb(239, 68, 68);
color: rgb(248, 113, 113);
}
.stop-btn:hover {
background: rgba(239, 68, 68, 0.3);
}
.expand-btn {
background: transparent;
}
.compact-live-indicator {
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background: rgb(239, 68, 68);
animation: pulse-live 1.5s ease-in-out infinite;
}
@keyframes pulse-live {
0%,
100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
50% {
opacity: 0.7;
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
}
}
/* Character animations (smaller scale for compact) */
@keyframes idle-bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes thinking-sway {
0%,
100% {
transform: rotate(-1deg);
}
50% {
transform: rotate(1deg);
}
}
@keyframes typing-bounce {
0%,
100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-2px) scale(1.01);
}
}
@keyframes searching-look {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-3px);
}
75% {
transform: translateX(3px);
}
}
@keyframes celebrate {
0%,
100% {
transform: scale(1) rotate(0deg);
}
50% {
transform: scale(1.05) rotate(3deg);
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25%,
75% {
transform: translateX(-3px);
}
50% {
transform: translateX(3px);
}
}
.animate-idle {
animation: idle-bob 3s ease-in-out infinite;
}
.animate-thinking {
animation: thinking-sway 2s ease-in-out infinite;
}
.animate-typing {
animation: typing-bounce 0.5s ease-in-out infinite;
}
.animate-searching {
animation: searching-look 1.5s ease-in-out infinite;
}
.animate-celebrate {
animation: celebrate 0.8s ease-in-out;
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
</style>
+366 -46
View File
@@ -3,7 +3,9 @@
configStore,
type HikariConfig,
type Theme,
type CustomThemeColors,
applyFontSize,
applyCustomThemeColors,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
DEFAULT_FONT_SIZE,
@@ -23,11 +25,30 @@
notifications_enabled: true,
notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
});
let showCustomThemeEditor = $state(false);
let isOpen = $state(false);
let isSaving = $state(false);
let saveError: string | null = $state(null);
@@ -84,9 +105,33 @@
async function handleThemeChange(theme: Theme) {
config.theme = theme;
await configStore.setTheme(theme);
showCustomThemeEditor = theme === "custom";
await configStore.setTheme(theme, config.custom_theme_colors);
}
function handleCustomColorChange(key: keyof CustomThemeColors, value: string) {
config.custom_theme_colors = {
...config.custom_theme_colors,
[key]: value || null,
};
// Live preview
if (config.theme === "custom") {
applyCustomThemeColors(config.custom_theme_colors);
}
}
// Default dark theme colors for color picker defaults
const defaultDarkColors: Required<Record<keyof CustomThemeColors, string>> = {
bg_primary: "#1a1a2e",
bg_secondary: "#16213e",
bg_terminal: "#0f0f1a",
accent_primary: "#e94560",
accent_secondary: "#ff6b9d",
text_primary: "#ffffff",
text_secondary: "#a0a0a0",
border_color: "#2a2a4a",
};
function toggleTool(tool: string) {
if (config.auto_granted_tools.includes(tool)) {
config.auto_granted_tools = config.auto_granted_tools.filter((t) => t !== tool);
@@ -186,47 +231,60 @@
<div class="mb-4">
<label for="api-key" class="block text-sm text-[var(--text-secondary)] mb-1">
API Key <span class="text-[var(--text-tertiary)]">(optional override)</span>
{#if config.streamer_mode}
<span class="text-yellow-500 text-xs ml-2">🔒 Hidden (Streamer Mode)</span>
{/if}
</label>
<div class="relative">
<input
id="api-key"
type={showApiKey ? "text" : "password"}
bind:value={config.api_key}
placeholder="Falls back to ~/.claude settings"
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
<button
type="button"
onclick={() => (showApiKey = !showApiKey)}
class="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{#if showApiKey}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
{:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{/if}
</button>
{#if config.streamer_mode}
<input
id="api-key"
type="password"
value="••••••••••••••••••••••••"
disabled
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-tertiary)] focus:outline-none cursor-not-allowed"
/>
{:else}
<input
id="api-key"
type={showApiKey ? "text" : "password"}
bind:value={config.api_key}
placeholder="Falls back to ~/.claude settings"
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
<button
type="button"
onclick={() => (showApiKey = !showApiKey)}
class="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{#if showApiKey}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
{:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{/if}
</button>
{/if}
</div>
</div>
@@ -385,7 +443,7 @@
<button
onclick={addCustomTool}
disabled={!newToolName.trim()}
class="px-3 py-1.5 text-sm bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)] text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg"
>
Add
</button>
@@ -400,11 +458,12 @@
<!-- Theme Selection -->
<div class="mb-4">
<label class="block text-sm text-[var(--text-secondary)] mb-2">Theme</label>
<div class="flex gap-2">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Theme</span>
<div class="flex flex-wrap gap-2" role="group" aria-label="Theme selection">
<button
onclick={() => handleThemeChange("dark")}
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark'
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
'dark'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
>
@@ -412,15 +471,187 @@
</button>
<button
onclick={() => handleThemeChange("light")}
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light'
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
'light'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
>
Light
</button>
<button
onclick={() => handleThemeChange("high-contrast")}
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
'high-contrast'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="High contrast mode for improved accessibility"
>
Contrast
</button>
<button
onclick={() => handleThemeChange("custom")}
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
'custom'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="Create your own custom theme"
>
Custom
</button>
</div>
</div>
<!-- Custom Theme Editor -->
{#if config.theme === "custom" || showCustomThemeEditor}
<div class="mb-4 p-3 bg-[var(--bg-primary)] rounded-lg border border-[var(--border-color)]">
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">Custom Theme Colors</h4>
<div class="grid grid-cols-2 gap-3">
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-primary"
>Background</label
>
<div class="flex gap-2 items-center">
<input
id="color-bg-primary"
type="color"
value={config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
oninput={(e) => handleCustomColorChange("bg_primary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-secondary"
>Secondary BG</label
>
<div class="flex gap-2 items-center">
<input
id="color-bg-secondary"
type="color"
value={config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
oninput={(e) => handleCustomColorChange("bg_secondary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-terminal"
>Terminal BG</label
>
<div class="flex gap-2 items-center">
<input
id="color-bg-terminal"
type="color"
value={config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
oninput={(e) => handleCustomColorChange("bg_terminal", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-border">Border</label>
<div class="flex gap-2 items-center">
<input
id="color-border"
type="color"
value={config.custom_theme_colors.border_color || defaultDarkColors.border_color}
oninput={(e) => handleCustomColorChange("border_color", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.border_color || defaultDarkColors.border_color}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-accent-primary"
>Accent Primary</label
>
<div class="flex gap-2 items-center">
<input
id="color-accent-primary"
type="color"
value={config.custom_theme_colors.accent_primary ||
defaultDarkColors.accent_primary}
oninput={(e) => handleCustomColorChange("accent_primary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.accent_primary || defaultDarkColors.accent_primary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-accent-secondary"
>Accent Secondary</label
>
<div class="flex gap-2 items-center">
<input
id="color-accent-secondary"
type="color"
value={config.custom_theme_colors.accent_secondary ||
defaultDarkColors.accent_secondary}
oninput={(e) =>
handleCustomColorChange("accent_secondary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.accent_secondary ||
defaultDarkColors.accent_secondary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-text-primary"
>Text Primary</label
>
<div class="flex gap-2 items-center">
<input
id="color-text-primary"
type="color"
value={config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
oninput={(e) => handleCustomColorChange("text_primary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-text-secondary"
>Text Secondary</label
>
<div class="flex gap-2 items-center">
<input
id="color-text-secondary"
type="color"
value={config.custom_theme_colors.text_secondary ||
defaultDarkColors.text_secondary}
oninput={(e) => handleCustomColorChange("text_secondary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.text_secondary || defaultDarkColors.text_secondary}
</span>
</div>
</div>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-3">
Changes preview live. Click Save Settings to persist.
</p>
</div>
{/if}
<!-- Font Size -->
<div class="mb-4">
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
@@ -477,6 +708,21 @@
</p>
</div>
<!-- Minimize to Tray Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.minimize_to_tray}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Minimize to system tray</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Hide to tray instead of closing when you click the X button
</p>
</div>
<!-- Update Checks Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
@@ -493,6 +739,45 @@
</div>
</section>
<!-- Privacy / Streamer Mode Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Privacy / Streamer Mode
</h3>
<!-- Streamer Mode Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.streamer_mode}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Enable streamer mode</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Hide sensitive information like API keys when streaming (Ctrl+Shift+S to toggle)
</p>
</div>
<!-- Hide Paths Toggle -->
{#if config.streamer_mode}
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.streamer_hide_paths}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Also hide file paths</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Mask directory paths (e.g., /home/user → /home/****)
</p>
</div>
{/if}
</section>
<!-- Notifications Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
@@ -543,7 +828,7 @@
<button
onclick={handleSave}
disabled={isSaving}
class="w-full py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)] text-white font-medium rounded-lg transition-colors disabled:opacity-50"
class="btn-trans-gradient w-full py-3 font-medium rounded-lg"
>
{isSaving ? "Saving..." : "Save Settings"}
</button>
@@ -580,4 +865,39 @@
background: var(--text-tertiary);
cursor: not-allowed;
}
/* Color picker styling */
.color-input-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.color-picker {
width: 32px;
height: 32px;
padding: 0;
border: 2px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
background: transparent;
}
.color-picker::-webkit-color-swatch-wrapper {
padding: 2px;
}
.color-picker::-webkit-color-swatch {
border-radius: 4px;
border: none;
}
.color-picker::-moz-color-swatch {
border-radius: 4px;
border: none;
}
.color-picker:hover {
border-color: var(--accent-primary);
}
</style>
+25 -21
View File
@@ -236,28 +236,32 @@
</div>
{/if}
{#if $conversations.size > 1}
<button
onclick={(e) => deleteTab(id, e)}
class="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
title="Close tab"
>
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
<div
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
>
{#if $conversations.size > 1}
<button
onclick={(e) => deleteTab(id, e)}
class="w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)]"
title="Close tab"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
</div>
{/each}
File diff suppressed because it is too large Load Diff
+274 -4
View File
@@ -7,6 +7,7 @@
import { characterState } from "$lib/stores/character";
import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri";
import { clipboardStore } from "$lib/stores/clipboard";
import {
setShouldRestoreHistory,
setSavedHistory,
@@ -24,7 +25,11 @@
isSlashCommand,
type SlashCommand,
} from "$lib/commands/slashCommands";
import { configStore, isStreamerMode } from "$lib/stores/config";
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
import type { Attachment } from "$lib/types/messages";
const INPUT_HISTORY_KEY = "hikari-input-history";
@@ -39,6 +44,14 @@
let selectedCommandIndex = $state(0);
let attachments = $state<Attachment[]>([]);
let isDragging = $state(false);
let showSnippetLibrary = $state(false);
let showQuickActions = $state(false);
let showClipboardHistory = $state(false);
let streamerModeActive = $state(false);
isStreamerMode.subscribe((value) => {
streamerModeActive = value;
});
// Input history state
let inputHistory = $state<string[]>([]);
@@ -500,6 +513,15 @@ User: ${formattedMessage}`;
const items = event.clipboardData?.items;
let handledFile = false;
// Also capture text content to clipboard history
const textContent = event.clipboardData?.getData("text/plain");
if (textContent && textContent.trim().length > 0) {
// Only capture multi-line or longer text (likely code snippets)
if (textContent.includes("\n") || textContent.length > 50) {
clipboardStore.captureClipboard(textContent, null, "Pasted into chat");
}
}
if (items && items.length > 0) {
for (const item of items) {
if (item.kind === "file") {
@@ -617,6 +639,62 @@ User: ${formattedMessage}`;
}
}
function handleSnippetInsert(content: string): void {
// Insert snippet at cursor position or append to input
if (inputValue.trim()) {
inputValue = inputValue + "\n\n" + content;
} else {
inputValue = content;
}
userHasTyped = true;
}
function handleClipboardInsert(content: string): void {
// Insert clipboard content at cursor position or append to input
if (inputValue.trim()) {
inputValue = inputValue + "\n\n" + content;
} else {
inputValue = content;
}
userHasTyped = true;
}
async function handleQuickAction(prompt: string): Promise<void> {
// Quick actions send the prompt directly
if (!isConnected || isSubmitting) return;
// Add to history
addToHistory(prompt);
historyIndex = -1;
tempInput = "";
userHasTyped = false;
isSubmitting = true;
// Reset notification state for new user message
handleNewUserMessage();
claudeStore.addLine("user", prompt);
characterState.setState("thinking");
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("send_prompt", {
conversationId,
message: prompt,
});
} catch (error) {
console.error("Failed to send quick action:", error);
claudeStore.addLine("error", `Failed to send: ${error}`);
characterState.setTemporaryState("error", 3000);
} finally {
isSubmitting = false;
}
}
function handleKeyDown(event: KeyboardEvent) {
// Handle command menu navigation
if (showCommandMenu && matchingCommands.length > 0) {
@@ -693,6 +771,99 @@ User: ${formattedMessage}`;
<div class="input-controls flex gap-2 mb-2">
<MessageModeSelector />
<button
type="button"
onclick={() => configStore.toggleStreamerMode()}
class="control-button streamer-toggle"
class:streamer-active={streamerModeActive}
title="Toggle Streamer Mode (Ctrl+Shift+S)"
>
{#if streamerModeActive}
<div class="live-indicator"></div>
<span>LIVE</span>
{:else}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
<span>Stream</span>
{/if}
</button>
<button
type="button"
onclick={() => (showQuickActions = true)}
class="control-button"
title="Quick Actions"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Actions</span>
</button>
<button
type="button"
onclick={() => (showSnippetLibrary = true)}
class="control-button"
title="Snippet Library"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<line x1="10" y1="9" x2="8" y2="9" />
</svg>
<span>Snippets</span>
</button>
<button
type="button"
onclick={() => (showClipboardHistory = true)}
class="control-button"
title="Clipboard History"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
<span>Clipboard</span>
</button>
</div>
<div class="input-row">
@@ -717,8 +888,7 @@ User: ${formattedMessage}`;
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
disabled:opacity-50 disabled:cursor-not-allowed"
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
></textarea>
</div>
@@ -744,7 +914,7 @@ User: ${formattedMessage}`;
<button
type="button"
onclick={handleInterrupt}
class="send-button bg-red-600 hover:bg-red-700"
class="send-button btn-trans-gradient"
title="Interrupt the current response (Ctrl+C)"
>
<span class="font-bold"></span> Stop
@@ -755,7 +925,7 @@ User: ${formattedMessage}`;
disabled={!isConnected ||
isSubmitting ||
(!inputValue.trim() && attachments.length === 0)}
class="send-button bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
class="send-button trans-gradient-button
disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if isSubmitting}
@@ -769,6 +939,25 @@ User: ${formattedMessage}`;
</div>
</form>
{#if showSnippetLibrary}
<SnippetLibraryPanel
onClose={() => (showSnippetLibrary = false)}
onInsert={handleSnippetInsert}
/>
{/if}
{#if showQuickActions}
<QuickActionsPanel onClose={() => (showQuickActions = false)} onAction={handleQuickAction} />
{/if}
{#if showClipboardHistory}
<ClipboardHistoryPanel
isOpen={showClipboardHistory}
onClose={() => (showClipboardHistory = false)}
onInsert={handleClipboardInsert}
/>
{/if}
<style>
.input-bar {
display: flex;
@@ -811,6 +1000,72 @@ User: ${formattedMessage}`;
gap: 8px;
}
.control-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
white-space: nowrap;
}
.control-button:hover {
background: var(--bg-tertiary);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.control-button:active {
transform: scale(0.95);
}
.streamer-toggle.streamer-active {
background: rgba(239, 68, 68, 0.2);
border-color: rgb(239, 68, 68);
color: rgb(248, 113, 113);
animation: pulse-red 2s ease-in-out infinite;
}
.streamer-toggle.streamer-active:hover {
background: rgba(239, 68, 68, 0.3);
border-color: rgb(248, 113, 113);
}
.live-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgb(239, 68, 68);
animation: pulse-dot 1.5s ease-in-out infinite;
}
@keyframes pulse-red {
0%,
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
50% {
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
}
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.input-row {
display: flex;
gap: 12px;
@@ -899,4 +1154,19 @@ User: ${formattedMessage}`;
.send-button:active:not(:disabled) {
transform: scale(0.95);
}
.trans-gradient-button {
background: var(--trans-gradient-vibrant);
border: none;
color: #1a1a2e;
font-weight: 600;
text-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
}
.trans-gradient-button:hover:not(:disabled) {
filter: brightness(1.1);
box-shadow:
0 0 20px rgba(91, 206, 250, 0.4),
0 0 30px rgba(245, 169, 184, 0.3);
}
</style>
@@ -12,6 +12,8 @@
{ keys: ["Escape"], description: "Close modals and panels" },
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
{ keys: ["Ctrl", ","], description: "Open settings" },
{ keys: ["Ctrl", "Shift", "M"], description: "Toggle compact mode" },
{ keys: ["Ctrl", "Shift", "S"], description: "Toggle streamer mode" },
],
},
{
+93 -4
View File
@@ -3,6 +3,7 @@
import hljs from "highlight.js";
import { onMount } from "svelte";
import { openUrl } from "@tauri-apps/plugin-opener";
import { clipboardStore } from "$lib/stores/clipboard";
interface Props {
content: string;
@@ -17,7 +18,20 @@
renderer.code = ({ text, lang }) => {
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
const highlighted = hljs.highlight(text, { language }).value;
return `<pre class="hljs-code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
const escapedText = text.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return `<div class="code-block-wrapper">
<div class="code-block-header">
<span class="code-block-lang">${language}</span>
<button class="copy-code-btn" data-code="${escapedText}" title="Copy code">
<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">Copy</span>
</button>
</div>
<pre class="hljs-code-block"><code class="hljs language-${language}">${highlighted}</code></pre>
</div>`;
};
renderer.codespan = ({ text }) => {
@@ -123,6 +137,34 @@
}
}
async function handleCopyClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const copyBtn = target.closest(".copy-code-btn") as HTMLButtonElement;
if (copyBtn) {
event.preventDefault();
const code = copyBtn.dataset.code
?.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
if (code) {
await navigator.clipboard.writeText(code);
// Capture to clipboard history
const langElement = copyBtn.parentElement?.querySelector(".code-block-lang");
const language = langElement?.textContent || null;
await clipboardStore.captureClipboard(code, language, "Claude response");
const textSpan = copyBtn.querySelector(".copy-text");
if (textSpan) {
textSpan.textContent = "Copied!";
setTimeout(() => {
textSpan.textContent = "Copy";
}, 2000);
}
}
}
}
onMount(() => {
if (containerElement) {
containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => {
@@ -138,6 +180,7 @@
onclick={(e) => {
handleSpoilerClick(e);
handleLinkClick(e);
handleCopyClick(e);
}}
onkeydown={handleSpoilerKeydown}
role="presentation"
@@ -163,13 +206,59 @@
margin-bottom: 0;
}
.markdown-content :global(.code-block-wrapper) {
margin: 0.75em 0;
border-radius: 6px;
border: 1px solid var(--border-color);
overflow: hidden;
}
.markdown-content :global(.code-block-header) {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-secondary);
padding: 0.4em 0.75em;
border-bottom: 1px solid var(--border-color);
font-size: 0.8em;
}
.markdown-content :global(.code-block-lang) {
color: var(--text-secondary);
font-family: "JetBrains Mono", "Fira Code", monospace;
text-transform: lowercase;
}
.markdown-content :global(.copy-code-btn) {
display: flex;
align-items: center;
gap: 0.4em;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.25em 0.5em;
border-radius: 4px;
font-size: 0.9em;
transition: all 0.15s ease;
}
.markdown-content :global(.copy-code-btn:hover) {
background: var(--bg-hover);
color: var(--text-primary);
}
.markdown-content :global(.copy-code-btn svg) {
flex-shrink: 0;
}
.markdown-content :global(.hljs-code-block) {
background: var(--bg-code, #1e1e2e);
border-radius: 6px;
border-radius: 0;
padding: 1em;
margin: 0.75em 0;
margin: 0;
overflow-x: auto;
border: 1px solid var(--border-color);
border: none;
}
.markdown-content :global(.hljs-code-block code) {
+929
View File
@@ -0,0 +1,929 @@
<script lang="ts">
import { configStore, type HikariConfig } from "$lib/stores/config";
import { formattedStats } from "$lib/stores/stats";
import { achievementsStore } from "$lib/stores/achievements";
import { open, save } from "@tauri-apps/plugin-dialog";
import { writeFile, readFile } from "@tauri-apps/plugin-fs";
export let onClose: () => void;
let config: HikariConfig;
configStore.config.subscribe((c) => (config = c));
let editingName = false;
let editingBio = false;
let nameInput = "";
let bioInput = "";
let avatarDataUrl: string | null = null;
// Initialize inputs when config is loaded
$: if (config) {
if (!editingName) nameInput = config.profile_name || "";
if (!editingBio) bioInput = config.profile_bio || "";
}
// Load avatar on mount and when path changes
let lastLoadedPath: string | null = null;
async function updateAvatarDisplay(path: string | null) {
if (path === lastLoadedPath) return;
lastLoadedPath = path;
if (path) {
avatarDataUrl = await loadAvatarAsDataUrl(path);
} else {
avatarDataUrl = null;
}
}
$: updateAvatarDisplay(config?.profile_avatar_path ?? null);
let isGeneratingImage = false;
$: unlockedCount = Object.values($achievementsStore.achievements).filter(
(a) => a.unlocked
).length;
$: totalAchievements = Object.values($achievementsStore.achievements).length;
$: achievementPercentage =
totalAchievements > 0 ? Math.round((unlockedCount / totalAchievements) * 100) : 0;
async function selectAvatar() {
try {
const selected = await open({
multiple: false,
filters: [
{
name: "Images",
extensions: ["png", "jpg", "jpeg", "gif", "webp"],
},
],
});
if (selected && typeof selected === "string") {
await configStore.updateConfig({ profile_avatar_path: selected });
}
} catch (error) {
console.error("Failed to select avatar:", error);
}
}
async function removeAvatar() {
await configStore.updateConfig({ profile_avatar_path: null });
}
async function saveName() {
await configStore.updateConfig({ profile_name: nameInput || null });
editingName = false;
}
async function saveBio() {
await configStore.updateConfig({ profile_bio: bioInput || null });
editingBio = false;
}
async function loadAvatarAsDataUrl(path: string): Promise<string | null> {
try {
const data = await readFile(path);
const extension = path.split(".").pop()?.toLowerCase() || "png";
const mimeType =
extension === "jpg" || extension === "jpeg"
? "image/jpeg"
: extension === "gif"
? "image/gif"
: extension === "webp"
? "image/webp"
: "image/png";
// Convert Uint8Array to base64 in chunks to avoid stack overflow
const blob = new Blob([data], { type: mimeType });
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
} catch (error) {
console.error("Failed to load avatar:", error);
return null;
}
}
async function generateShareImage(): Promise<Blob> {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
// Card dimensions (1080p for sharing)
const width = 1920;
const height = 1080;
canvas.width = width;
canvas.height = height;
// Background gradient (dark theme)
const bgGradient = ctx.createLinearGradient(0, 0, width, height);
bgGradient.addColorStop(0, "#1a1a2e");
bgGradient.addColorStop(1, "#16213e");
ctx.fillStyle = bgGradient;
ctx.fillRect(0, 0, width, height);
// Trans flag stripe accent at top
const stripeHeight = 25;
const stripeColors = ["#5bcefa", "#f5a9b8", "#ffffff", "#f5a9b8", "#5bcefa"];
stripeColors.forEach((color, i) => {
ctx.fillStyle = color;
ctx.fillRect(0, i * (stripeHeight / 5) * 2, width, (stripeHeight / 5) * 2);
});
// Border
ctx.strokeStyle = "#5bcefa";
ctx.lineWidth = 6;
ctx.strokeRect(3, 3, width - 6, height - 6);
// Avatar circle
const avatarX = 200;
const avatarY = 220;
const avatarRadius = 140;
ctx.save();
ctx.beginPath();
ctx.arc(avatarX, avatarY, avatarRadius, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
// Draw avatar if available, otherwise gradient placeholder
let avatarLoaded = false;
if (config.profile_avatar_path) {
try {
const dataUrl = await loadAvatarAsDataUrl(config.profile_avatar_path);
if (dataUrl) {
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject();
img.src = dataUrl;
});
ctx.drawImage(
img,
avatarX - avatarRadius,
avatarY - avatarRadius,
avatarRadius * 2,
avatarRadius * 2
);
avatarLoaded = true;
}
} catch {
// Will use fallback gradient
}
}
if (!avatarLoaded) {
const avatarGradient = ctx.createLinearGradient(
avatarX - avatarRadius,
avatarY - avatarRadius,
avatarX + avatarRadius,
avatarY + avatarRadius
);
avatarGradient.addColorStop(0, "#5bcefa");
avatarGradient.addColorStop(0.5, "#f5a9b8");
avatarGradient.addColorStop(1, "#5bcefa");
ctx.fillStyle = avatarGradient;
ctx.fillRect(
avatarX - avatarRadius,
avatarY - avatarRadius,
avatarRadius * 2,
avatarRadius * 2
);
}
ctx.restore();
// Avatar border
ctx.beginPath();
ctx.arc(avatarX, avatarY, avatarRadius + 6, 0, Math.PI * 2);
ctx.strokeStyle = "#f5a9b8";
ctx.lineWidth = 8;
ctx.stroke();
// Name
ctx.fillStyle = "#ffffff";
ctx.font = "bold 72px system-ui, -apple-system, sans-serif";
ctx.fillText(config.profile_name || "Hikari User", 400, 180);
// Bio (truncated)
ctx.fillStyle = "#9ca3af";
ctx.font = "36px system-ui, -apple-system, sans-serif";
const bio = config.profile_bio || "A Hikari Desktop user";
const truncatedBio = bio.length > 100 ? bio.substring(0, 97) + "..." : bio;
ctx.fillText(truncatedBio, 400, 260);
// Stats section
const statsY = 420;
ctx.fillStyle = "#6b7280";
ctx.font = "bold 32px system-ui, -apple-system, sans-serif";
ctx.fillText("LIFETIME STATS", 80, statsY);
// Stats grid
const stats = [
{ label: "Messages", value: $formattedStats.messagesTotal },
{ label: "Tokens", value: $formattedStats.totalTokens },
{ label: "Code Blocks", value: $formattedStats.codeBlocksTotal },
{ label: "Files Edited", value: $formattedStats.filesEditedTotal },
{ label: "Files Created", value: $formattedStats.filesCreatedTotal },
{ label: "Total Cost", value: $formattedStats.totalCost },
];
const statBoxWidth = 540;
const statBoxHeight = 160;
const statsPerRow = 3;
const startX = 80;
const startY = statsY + 40;
stats.forEach((stat, i) => {
const row = Math.floor(i / statsPerRow);
const col = i % statsPerRow;
const x = startX + col * (statBoxWidth + 50);
const y = startY + row * (statBoxHeight + 30);
// Stat box background
ctx.fillStyle = "rgba(255, 255, 255, 0.05)";
ctx.beginPath();
ctx.roundRect(x, y, statBoxWidth, statBoxHeight, 20);
ctx.fill();
// Stat value
ctx.fillStyle = "#5bcefa";
ctx.font = "bold 56px system-ui, -apple-system, sans-serif";
ctx.fillText(stat.value, x + 32, y + 75);
// Stat label
ctx.fillStyle = "#9ca3af";
ctx.font = "28px system-ui, -apple-system, sans-serif";
ctx.fillText(stat.label.toUpperCase(), x + 32, y + 128);
});
// Achievement progress
const achieveY = 870;
ctx.fillStyle = "#6b7280";
ctx.font = "bold 32px system-ui, -apple-system, sans-serif";
ctx.fillText("ACHIEVEMENTS", 80, achieveY);
// Progress bar background
const progressX = 80;
const progressY = achieveY + 30;
const progressWidth = 1200;
const progressHeight = 44;
ctx.fillStyle = "rgba(255, 255, 255, 0.1)";
ctx.beginPath();
ctx.roundRect(progressX, progressY, progressWidth, progressHeight, 22);
ctx.fill();
// Progress bar fill
const progressGradient = ctx.createLinearGradient(progressX, 0, progressX + progressWidth, 0);
progressGradient.addColorStop(0, "#5bcefa");
progressGradient.addColorStop(0.5, "#f5a9b8");
progressGradient.addColorStop(1, "#5bcefa");
ctx.fillStyle = progressGradient;
ctx.beginPath();
ctx.roundRect(
progressX,
progressY,
progressWidth * (achievementPercentage / 100),
progressHeight,
22
);
ctx.fill();
// Progress text
ctx.fillStyle = "#ffffff";
ctx.font = "bold 36px system-ui, -apple-system, sans-serif";
ctx.fillText(
`${unlockedCount} / ${totalAchievements} (${achievementPercentage}%)`,
progressX + progressWidth + 40,
progressY + 34
);
// Hikari branding
ctx.fillStyle = "#f5a9b8";
ctx.font = "bold 42px system-ui, -apple-system, sans-serif";
ctx.fillText("✨ Hikari Desktop", 80, height - 100);
// Discord promo
ctx.fillStyle = "#9ca3af";
ctx.font = "34px system-ui, -apple-system, sans-serif";
ctx.fillText("Join our community: chat.nhcarrigan.com", 80, height - 45);
// Convert canvas to blob
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob!), "image/png");
});
}
async function shareProfile() {
isGeneratingImage = true;
try {
const blob = await generateShareImage();
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const filePath = await save({
filters: [{ name: "PNG Image", extensions: ["png"] }],
defaultPath: `hikari-profile-${config.profile_name?.replace(/\s+/g, "-").toLowerCase() || "user"}.png`,
});
if (filePath) {
await writeFile(filePath, uint8Array);
}
} catch (error) {
console.error("Failed to generate share image:", error);
} finally {
isGeneratingImage = false;
}
}
</script>
<div class="profile-overlay" role="dialog" aria-modal="true">
<div class="profile-panel">
<div class="profile-header">
<h2>Profile</h2>
<button class="close-btn" on:click={onClose} aria-label="Close profile">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="profile-content">
<!-- Avatar Section -->
<div class="avatar-section">
<div
class="avatar-container"
on:click={selectAvatar}
role="button"
tabindex="0"
on:keydown={(e) => e.key === "Enter" && selectAvatar()}
>
{#if avatarDataUrl}
<img src={avatarDataUrl} alt="Profile avatar" class="avatar-image" />
<div class="avatar-overlay">
<span>Change</span>
</div>
{:else}
<div class="avatar-placeholder">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>Add Photo</span>
</div>
{/if}
</div>
{#if config.profile_avatar_path}
<button class="remove-avatar-btn" on:click={removeAvatar}>Remove Photo</button>
{/if}
</div>
<!-- Name Section -->
<div class="name-section">
{#if editingName}
<input
type="text"
bind:value={nameInput}
placeholder="Enter your name"
class="name-input"
on:keydown={(e) => e.key === "Enter" && saveName()}
on:blur={saveName}
/>
{:else}
<button
class="name-display"
on:click={() => {
editingName = true;
nameInput = config.profile_name || "";
}}
>
<span class="name-text">{config.profile_name || "Click to add name"}</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{/if}
</div>
<!-- Bio Section -->
<div class="bio-section">
<h3>Bio</h3>
{#if editingBio}
<textarea
bind:value={bioInput}
placeholder="Tell us about yourself..."
class="bio-input"
rows="3"
on:blur={saveBio}
></textarea>
<button class="save-bio-btn btn-trans-gradient" on:click={saveBio}>Save</button>
{:else}
<button
class="bio-display"
on:click={() => {
editingBio = true;
bioInput = config.profile_bio || "";
}}
>
<span class="bio-text">{config.profile_bio || "Click to add a bio..."}</span>
</button>
{/if}
</div>
<!-- Stats Section -->
<div class="stats-section">
<h3>Lifetime Stats</h3>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value">{$formattedStats.messagesTotal}</span>
<span class="stat-label">Messages</span>
</div>
<div class="stat-card">
<span class="stat-value">{$formattedStats.totalTokens}</span>
<span class="stat-label">Tokens</span>
</div>
<div class="stat-card">
<span class="stat-value">{$formattedStats.codeBlocksTotal}</span>
<span class="stat-label">Code Blocks</span>
</div>
<div class="stat-card">
<span class="stat-value">{$formattedStats.filesEditedTotal}</span>
<span class="stat-label">Files Edited</span>
</div>
<div class="stat-card">
<span class="stat-value">{$formattedStats.filesCreatedTotal}</span>
<span class="stat-label">Files Created</span>
</div>
<div class="stat-card">
<span class="stat-value">{$formattedStats.totalCost}</span>
<span class="stat-label">Total Cost</span>
</div>
</div>
</div>
<!-- Achievements Section -->
<div class="achievements-section">
<h3>Achievements</h3>
<div class="achievement-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: {achievementPercentage}%"></div>
</div>
<span class="progress-text"
>{unlockedCount} / {totalAchievements} ({achievementPercentage}%)</span
>
</div>
</div>
<!-- Share Section -->
<div class="share-section">
<button
class="share-btn btn-trans-gradient"
on:click={shareProfile}
disabled={isGeneratingImage}
>
{#if isGeneratingImage}
<span class="spinner"></span>
Generating...
{:else}
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
Share Profile
{/if}
</button>
<p class="share-hint">Generate a shareable image of your profile</p>
</div>
</div>
</div>
</div>
<style>
.profile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.profile-panel {
background: var(--bg-secondary);
border-radius: 16px;
width: 90%;
max-width: 480px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
border: 1px solid var(--border-color);
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
background: var(--bg-secondary);
z-index: 1;
}
.profile-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
}
.close-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.profile-content {
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
/* Avatar */
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.avatar-container {
width: 120px;
height: 120px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
position: relative;
background: var(--bg-tertiary);
border: 3px solid var(--trans-pink);
box-shadow: 0 0 20px rgba(245, 169, 184, 0.3);
transition: all 0.3s;
}
.avatar-container:hover {
border-color: var(--trans-blue);
box-shadow: 0 0 30px rgba(91, 206, 250, 0.4);
transform: scale(1.02);
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
color: white;
font-size: 14px;
font-weight: 500;
}
.avatar-container:hover .avatar-overlay {
opacity: 1;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-secondary);
}
.avatar-placeholder span {
font-size: 12px;
}
.remove-avatar-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
text-decoration: underline;
}
.remove-avatar-btn:hover {
color: var(--trans-pink);
}
/* Name */
.name-section {
text-align: center;
}
.name-display {
display: inline-flex;
align-items: center;
gap: 8px;
background: none;
border: none;
cursor: pointer;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.2s;
}
.name-display:hover {
background: var(--bg-tertiary);
}
.name-display svg {
color: var(--text-secondary);
opacity: 0;
transition: opacity 0.2s;
}
.name-display:hover svg {
opacity: 1;
}
.name-text {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
}
.name-input {
width: 100%;
max-width: 300px;
padding: 12px 16px;
font-size: 24px;
font-weight: 600;
text-align: center;
background: var(--bg-tertiary);
border: 2px solid var(--trans-pink);
border-radius: 8px;
color: var(--text-primary);
outline: none;
}
.name-input:focus {
border-color: var(--trans-blue);
box-shadow: 0 0 10px rgba(91, 206, 250, 0.3);
}
/* Bio */
.bio-section h3,
.stats-section h3,
.achievements-section h3 {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.bio-display {
width: 100%;
text-align: left;
background: var(--bg-tertiary);
border: 1px solid transparent;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.2s;
}
.bio-display:hover {
border-color: var(--border-color);
}
.bio-text {
color: var(--text-secondary);
font-size: 14px;
line-height: 1.5;
}
.bio-input {
width: 100%;
padding: 12px;
font-size: 14px;
background: var(--bg-tertiary);
border: 2px solid var(--trans-pink);
border-radius: 8px;
color: var(--text-primary);
resize: none;
outline: none;
font-family: inherit;
}
.bio-input:focus {
border-color: var(--trans-blue);
box-shadow: 0 0 10px rgba(91, 206, 250, 0.3);
}
.save-bio-btn {
margin-top: 8px;
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.stat-card {
background: var(--bg-tertiary);
border-radius: 12px;
padding: 16px 12px;
text-align: center;
display: flex;
flex-direction: column;
gap: 4px;
border: 1px solid var(--border-color);
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
background: linear-gradient(135deg, var(--trans-blue), var(--trans-pink));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Achievements */
.achievement-progress {
display: flex;
flex-direction: column;
gap: 8px;
}
.progress-bar {
height: 12px;
background: var(--bg-tertiary);
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.progress-fill {
height: 100%;
background: linear-gradient(
90deg,
var(--trans-blue),
var(--trans-pink),
var(--trans-white),
var(--trans-pink),
var(--trans-blue)
);
background-size: 200% 100%;
animation: shimmer 3s linear infinite;
border-radius: 6px;
transition: width 0.5s ease;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.progress-text {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
}
/* Share */
.share-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
}
.share-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
transition: all 0.2s;
}
.share-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.share-btn svg {
flex-shrink: 0;
}
.share-hint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+456
View File
@@ -0,0 +1,456 @@
<script lang="ts">
import { onMount } from "svelte";
import { quickActionsStore, type QuickAction } from "$lib/stores/quickActions";
interface Props {
onClose: () => void;
onAction: (prompt: string) => void;
}
const { onClose, onAction }: Props = $props();
let editingAction = $state<QuickAction | null>(null);
let isCreating = $state(false);
let showDeleteConfirm = $state<string | null>(null);
let editName = $state("");
let editPrompt = $state("");
let editIcon = $state("zap");
const actions = $derived(quickActionsStore.actions);
const isLoading = $derived(quickActionsStore.isLoading);
const availableIcons = [
{ id: "zap", label: "Lightning" },
{ id: "play", label: "Play" },
{ id: "file-text", label: "File" },
{ id: "alert-circle", label: "Alert" },
{ id: "check-square", label: "Check" },
{ id: "refresh-cw", label: "Refresh" },
{ id: "git-pull-request", label: "Git PR" },
{ id: "code", label: "Code" },
{ id: "search", label: "Search" },
{ id: "terminal", label: "Terminal" },
{ id: "bug", label: "Bug" },
{ id: "shield", label: "Shield" },
];
onMount(() => {
quickActionsStore.loadQuickActions();
});
function handleAction(action: QuickAction): void {
onAction(action.prompt);
onClose();
}
function handleStartCreate(): void {
isCreating = true;
editingAction = null;
editName = "";
editPrompt = "";
editIcon = "zap";
}
function handleStartEdit(action: QuickAction): void {
editingAction = action;
isCreating = false;
editName = action.name;
editPrompt = action.prompt;
editIcon = action.icon;
}
function handleCancelEdit(): void {
editingAction = null;
isCreating = false;
}
async function handleSave(): Promise<void> {
if (!editName.trim() || !editPrompt.trim()) {
return;
}
if (isCreating) {
await quickActionsStore.createQuickAction(editName.trim(), editPrompt.trim(), editIcon);
} else if (editingAction) {
await quickActionsStore.updateQuickAction(
editingAction.id,
editName.trim(),
editPrompt.trim(),
editIcon
);
}
handleCancelEdit();
}
async function handleDelete(actionId: string): Promise<void> {
await quickActionsStore.deleteQuickAction(actionId);
showDeleteConfirm = null;
}
function getIconSvg(icon: string): string {
const icons: Record<string, string> = {
"git-pull-request":
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 3v12M18 9a3 3 0 100 6 3 3 0 000-6zm0 0V3m0 18v-6M6 21a3 3 0 100-6 3 3 0 000 6z" />',
play: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
"file-text":
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />',
"alert-circle":
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
"check-square":
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />',
"refresh-cw":
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />',
zap: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />',
code: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />',
search:
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />',
terminal:
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />',
bug: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />',
shield:
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />',
};
return icons[icon] || icons["zap"];
}
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="quick-actions-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
{#if editingAction || isCreating}
<button
onclick={handleCancelEdit}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Back to list"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{/if}
<h2 id="quick-actions-title" class="text-xl font-semibold text-[var(--text-primary)]">
{#if isCreating}
Create Quick Action
{:else if editingAction}
Edit Quick Action
{:else}
Quick Actions
{/if}
</h2>
</div>
<div class="flex items-center gap-2">
{#if !editingAction && !isCreating}
<button
onclick={handleStartCreate}
class="px-3 py-1.5 text-sm font-medium btn-trans-gradient rounded flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
New Action
</button>
{/if}
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{#if editingAction || isCreating}
<div class="p-6 flex-1 overflow-y-auto">
<div class="space-y-4">
<div>
<label
for="action-name"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Name
</label>
<input
id="action-name"
type="text"
bind:value={editName}
placeholder="Enter action name..."
class="w-full px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<div>
<label
for="action-icon"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Icon
</label>
<div class="grid grid-cols-6 gap-2">
{#each availableIcons as icon (icon.id)}
<button
onclick={() => (editIcon = icon.id)}
class="p-3 rounded-lg border transition-colors {editIcon === icon.id
? 'bg-[var(--accent-primary)]/10 border-[var(--accent-primary)] text-[var(--accent-primary)]'
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]/50'}"
title={icon.label}
>
<svg
class="w-5 h-5 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Icons are from controlled internal function -->
{@html getIconSvg(icon.id)}
</svg>
</button>
{/each}
</div>
</div>
<div>
<label
for="action-prompt"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Prompt
</label>
<textarea
id="action-prompt"
bind:value={editPrompt}
placeholder="Enter the prompt to send..."
rows="4"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none font-mono text-sm"
></textarea>
</div>
<div class="flex justify-end gap-2 pt-4">
<button
onclick={handleCancelEdit}
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
>
Cancel
</button>
<button
onclick={handleSave}
disabled={!editName.trim() || !editPrompt.trim()}
class="px-4 py-2 text-sm font-medium btn-trans-gradient rounded-lg"
>
{isCreating ? "Create" : "Save"}
</button>
</div>
</div>
</div>
{:else}
<div class="flex-1 overflow-y-auto p-6">
{#if $isLoading}
<div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading quick actions...</div>
</div>
{:else if $actions.length === 0}
<div class="flex flex-col items-center justify-center p-8 text-center">
<svg
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<p class="text-[var(--text-secondary)]">No quick actions available</p>
<button
onclick={handleStartCreate}
class="mt-4 px-4 py-2 text-sm font-medium btn-trans-gradient rounded-lg"
>
Create your first action
</button>
</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
{#each $actions as action (action.id)}
<div
class="group relative bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg hover:border-[var(--accent-primary)]/50 transition-colors"
>
<button onclick={() => handleAction(action)} class="w-full p-4 text-left">
<div class="flex items-center gap-3 mb-2">
<div
class="w-10 h-10 rounded-lg bg-[var(--accent-primary)]/10 flex items-center justify-center text-[var(--accent-primary)]"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Icons are from controlled internal function -->
{@html getIconSvg(action.icon)}
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-medium text-[var(--text-primary)] truncate">{action.name}</h3>
{#if action.is_default}
<span class="text-xs text-[var(--text-tertiary)]">Default</span>
{:else}
<span class="text-xs text-[var(--accent-secondary)]">Custom</span>
{/if}
</div>
</div>
<p class="text-xs text-[var(--text-tertiary)] line-clamp-2">{action.prompt}</p>
</button>
<div
class="absolute top-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
onclick={(e) => {
e.stopPropagation();
handleStartEdit(action);
}}
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)] rounded transition-colors"
title="Edit action"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
{#if !action.is_default}
{#if showDeleteConfirm === action.id}
<div class="flex items-center gap-1 bg-[var(--bg-primary)] rounded p-1">
<button
onclick={(e) => {
e.stopPropagation();
handleDelete(action.id);
}}
class="px-2 py-0.5 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Delete
</button>
<button
onclick={(e) => {
e.stopPropagation();
showDeleteConfirm = null;
}}
class="px-2 py-0.5 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
>
Cancel
</button>
</div>
{:else}
<button
onclick={(e) => {
e.stopPropagation();
showDeleteConfirm = action.id;
}}
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 bg-[var(--bg-primary)] rounded transition-colors"
title="Delete action"
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
@@ -0,0 +1,476 @@
<script lang="ts">
import { onMount } from "svelte";
import { sessionsStore, type SessionListItem, type SavedSession } from "$lib/stores/sessions";
import { conversationsStore } from "$lib/stores/conversations";
import type { TerminalLine } from "$lib/types/messages";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
let searchInput = $state("");
let selectedSession = $state<SavedSession | null>(null);
let showDeleteConfirm = $state<string | null>(null);
let showExportMenu = $state<string | null>(null);
let isImporting = $state(false);
const sessions = $derived(sessionsStore.sessions);
const isLoading = $derived(sessionsStore.isLoading);
onMount(() => {
sessionsStore.loadSessions();
});
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return `Today at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
} else if (diffDays === 1) {
return `Yesterday at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else {
return date.toLocaleDateString([], {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
}
async function handleSearch(): Promise<void> {
await sessionsStore.searchSessions(searchInput);
}
async function handleViewSession(session: SessionListItem): Promise<void> {
const fullSession = await sessionsStore.loadSession(session.id);
selectedSession = fullSession;
}
async function handleResumeSession(session: SessionListItem): Promise<void> {
const fullSession = await sessionsStore.loadSession(session.id);
if (!fullSession) return;
const newConvId = conversationsStore.createConversation(fullSession.name);
for (const msg of fullSession.messages) {
const line: TerminalLine = {
id: msg.id,
type: msg.type as TerminalLine["type"],
content: msg.content,
timestamp: new Date(msg.timestamp),
toolName: msg.tool_name,
};
conversationsStore.addLineToConversation(newConvId, line.type, line.content, line.toolName);
}
if (fullSession.working_directory) {
conversationsStore.setWorkingDirectoryForConversation(
newConvId,
fullSession.working_directory
);
}
onClose();
}
async function handleDeleteSession(sessionId: string): Promise<void> {
await sessionsStore.deleteSession(sessionId);
showDeleteConfirm = null;
if (selectedSession?.id === sessionId) {
selectedSession = null;
}
}
function handleBackToList(): void {
selectedSession = null;
}
async function handleExportJson(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsJson(sessionId);
}
async function handleExportMarkdown(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsMarkdown(sessionId);
}
async function handleExportHtml(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsHtml(sessionId);
}
async function handleExportPdf(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsPdf(sessionId);
}
async function handleImport(): Promise<void> {
isImporting = true;
try {
await sessionsStore.importSession();
} finally {
isImporting = false;
}
}
function toggleExportMenu(sessionId: string): void {
if (showExportMenu === sessionId) {
showExportMenu = null;
} else {
showExportMenu = sessionId;
showDeleteConfirm = null;
}
}
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-3xl w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="session-history-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
{#if selectedSession}
<button
onclick={handleBackToList}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Back to list"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{/if}
<h2 id="session-history-title" class="text-xl font-semibold text-[var(--text-primary)]">
{selectedSession ? selectedSession.name : "Session History"}
</h2>
</div>
<div class="flex items-center gap-2">
{#if !selectedSession}
<button
onclick={handleImport}
disabled={isImporting}
class="px-3 py-1.5 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
title="Import session"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
{isImporting ? "Importing..." : "Import"}
</button>
{/if}
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{#if selectedSession}
<div class="overflow-y-auto flex-1 p-6">
<div class="text-sm text-[var(--text-tertiary)] mb-4">
<span>{formatDate(selectedSession.created_at)}</span>
<span class="mx-2"></span>
<span>{selectedSession.message_count} messages</span>
{#if selectedSession.working_directory}
<span class="mx-2"></span>
<span class="font-mono text-xs">{selectedSession.working_directory}</span>
{/if}
</div>
<div class="space-y-3">
{#each selectedSession.messages as message (message.id)}
<div
class="p-3 rounded-lg {message.type === 'user'
? 'bg-[var(--accent-primary)]/10 border border-[var(--accent-primary)]/20'
: message.type === 'assistant'
? 'bg-[var(--bg-secondary)]'
: message.type === 'error'
? 'bg-red-500/10 border border-red-500/20'
: 'bg-[var(--bg-tertiary)]'}"
>
<div class="flex items-center gap-2 mb-1">
<span
class="text-xs font-medium {message.type === 'user'
? 'text-[var(--accent-primary)]'
: message.type === 'assistant'
? 'text-[var(--text-primary)]'
: message.type === 'error'
? 'text-red-400'
: 'text-[var(--text-tertiary)]'}"
>
{message.type === "user"
? "You"
: message.type === "assistant"
? "Hikari"
: message.type === "tool"
? message.tool_name || "Tool"
: message.type}
</span>
</div>
<p class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap break-words">
{message.content.length > 500
? message.content.slice(0, 500) + "..."
: message.content}
</p>
</div>
{/each}
</div>
</div>
{:else}
<div class="p-4 border-b border-[var(--border-color)]">
<div class="relative">
<input
type="text"
placeholder="Search sessions..."
bind:value={searchInput}
oninput={handleSearch}
class="w-full px-4 py-2 pl-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
<div class="overflow-y-auto flex-1">
{#if $isLoading}
<div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
</div>
{:else if $sessions.length === 0}
<div class="flex flex-col items-center justify-center p-8 text-center">
<svg
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<p class="text-[var(--text-secondary)]">No saved sessions yet</p>
<p class="text-sm text-[var(--text-tertiary)] mt-1">
Your conversations will appear here once saved
</p>
</div>
{:else}
<div class="divide-y divide-[var(--border-color)]">
{#each $sessions as session (session.id)}
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
<div class="flex items-start justify-between gap-4">
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
<div class="flex items-center gap-2 mb-1">
<h3 class="font-medium text-[var(--text-primary)]">{session.name}</h3>
<span class="text-xs text-[var(--text-tertiary)]">
{session.message_count} messages
</span>
</div>
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 mb-2">
{session.preview}
</p>
<div class="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<span>{formatDate(session.last_activity_at)}</span>
{#if session.working_directory}
<span></span>
<span class="font-mono truncate max-w-[200px]">
{session.working_directory}
</span>
{/if}
</div>
</button>
<div
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
onclick={() => handleResumeSession(session)}
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
title="Resume this session"
>
Resume
</button>
<div class="relative">
<button
onclick={() => toggleExportMenu(session.id)}
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
title="Export session"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
{#if showExportMenu === session.id}
<div
class="absolute right-0 top-full mt-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-lg py-1 z-10 min-w-[140px]"
>
<button
onclick={() => handleExportJson(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as JSON
</button>
<button
onclick={() => handleExportMarkdown(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as Markdown
</button>
<button
onclick={() => handleExportHtml(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as HTML
</button>
<button
onclick={() => handleExportPdf(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as PDF
</button>
</div>
{/if}
</div>
{#if showDeleteConfirm === session.id}
<div class="flex items-center gap-1">
<button
onclick={() => handleDeleteSession(session.id)}
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Confirm
</button>
<button
onclick={() => (showDeleteConfirm = null)}
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
>
Cancel
</button>
</div>
{:else}
<button
onclick={() => (showDeleteConfirm = session.id)}
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
title="Delete session"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
@@ -0,0 +1,467 @@
<script lang="ts">
import { onMount } from "svelte";
import { snippetsStore, type Snippet } from "$lib/stores/snippets";
interface Props {
onClose: () => void;
onInsert: (content: string) => void;
}
const { onClose, onInsert }: Props = $props();
let editingSnippet = $state<Snippet | null>(null);
let isCreating = $state(false);
let showDeleteConfirm = $state<string | null>(null);
let editName = $state("");
let editContent = $state("");
let editCategory = $state("");
let newCategoryInput = $state("");
let showNewCategoryInput = $state(false);
const snippets = $derived(snippetsStore.filteredSnippets);
const categories = $derived(snippetsStore.categories);
const selectedCategory = $derived(snippetsStore.selectedCategory);
const isLoading = $derived(snippetsStore.isLoading);
onMount(() => {
snippetsStore.loadSnippets();
});
function handleSelectCategory(category: string | null): void {
snippetsStore.setSelectedCategory(category);
}
function handleInsert(snippet: Snippet): void {
onInsert(snippet.content);
onClose();
}
function handleStartCreate(): void {
isCreating = true;
editingSnippet = null;
editName = "";
editContent = "";
editCategory = $categories.length > 0 ? $categories[0] : "Custom";
showNewCategoryInput = false;
newCategoryInput = "";
}
function handleStartEdit(snippet: Snippet): void {
editingSnippet = snippet;
isCreating = false;
editName = snippet.name;
editContent = snippet.content;
editCategory = snippet.category;
showNewCategoryInput = false;
newCategoryInput = "";
}
function handleCancelEdit(): void {
editingSnippet = null;
isCreating = false;
showNewCategoryInput = false;
newCategoryInput = "";
}
async function handleSave(): Promise<void> {
const finalCategory =
showNewCategoryInput && newCategoryInput.trim() ? newCategoryInput.trim() : editCategory;
if (!editName.trim() || !editContent.trim() || !finalCategory) {
return;
}
if (isCreating) {
await snippetsStore.createSnippet(editName.trim(), editContent.trim(), finalCategory);
} else if (editingSnippet) {
await snippetsStore.updateSnippet(
editingSnippet.id,
editName.trim(),
editContent.trim(),
finalCategory
);
}
handleCancelEdit();
}
async function handleDelete(snippetId: string): Promise<void> {
await snippetsStore.deleteSnippet(snippetId);
showDeleteConfirm = null;
}
function toggleNewCategory(): void {
showNewCategoryInput = !showNewCategoryInput;
if (showNewCategoryInput) {
newCategoryInput = "";
}
}
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="snippet-library-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
{#if editingSnippet || isCreating}
<button
onclick={handleCancelEdit}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Back to list"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
{/if}
<h2 id="snippet-library-title" class="text-xl font-semibold text-[var(--text-primary)]">
{#if isCreating}
Create Snippet
{:else if editingSnippet}
Edit Snippet
{:else}
Snippet Library
{/if}
</h2>
</div>
<div class="flex items-center gap-2">
{#if !editingSnippet && !isCreating}
<button
onclick={handleStartCreate}
class="btn-trans-gradient px-3 py-1.5 text-sm font-medium rounded flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
New Snippet
</button>
{/if}
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{#if editingSnippet || isCreating}
<div class="p-6 flex-1 overflow-y-auto">
<div class="space-y-4">
<div>
<label
for="snippet-name"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Name
</label>
<input
id="snippet-name"
type="text"
bind:value={editName}
placeholder="Enter snippet name..."
class="w-full px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<div>
<label
for="snippet-category"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Category
</label>
<div class="flex items-center gap-2">
{#if showNewCategoryInput}
<input
type="text"
bind:value={newCategoryInput}
placeholder="Enter new category..."
class="flex-1 px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
{:else}
<select
id="snippet-category"
bind:value={editCategory}
class="flex-1 px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
>
{#each $categories as category (category)}
<option value={category}>{category}</option>
{/each}
<option value="Custom">Custom</option>
</select>
{/if}
<button
onclick={toggleNewCategory}
class="px-3 py-2 text-sm font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
{showNewCategoryInput ? "Use Existing" : "New Category"}
</button>
</div>
</div>
<div>
<label
for="snippet-content"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Content
</label>
<textarea
id="snippet-content"
bind:value={editContent}
placeholder="Enter your prompt snippet..."
rows="8"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none font-mono text-sm"
></textarea>
</div>
<div class="flex justify-end gap-2 pt-4">
<button
onclick={handleCancelEdit}
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
>
Cancel
</button>
<button
onclick={handleSave}
disabled={!editName.trim() || !editContent.trim()}
class="btn-trans-gradient px-4 py-2 text-sm font-medium rounded-lg"
>
{isCreating ? "Create" : "Save"}
</button>
</div>
</div>
</div>
{:else}
<div class="flex flex-1 overflow-hidden">
<div class="w-48 border-r border-[var(--border-color)] p-4 overflow-y-auto">
<h3
class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wider mb-3"
>
Categories
</h3>
<div class="space-y-1">
<button
onclick={() => handleSelectCategory(null)}
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {$selectedCategory ===
null
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]'}"
>
All Snippets
</button>
{#each $categories as category (category)}
<button
onclick={() => handleSelectCategory(category)}
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {$selectedCategory ===
category
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]'}"
>
{category}
</button>
{/each}
</div>
</div>
<div class="flex-1 overflow-y-auto">
{#if $isLoading}
<div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading snippets...</div>
</div>
{:else if $snippets.length === 0}
<div class="flex flex-col items-center justify-center p-8 text-center">
<svg
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p class="text-[var(--text-secondary)]">No snippets in this category</p>
<button
onclick={handleStartCreate}
class="btn-trans-gradient mt-4 px-4 py-2 text-sm font-medium rounded-lg"
>
Create your first snippet
</button>
</div>
{:else}
<div class="divide-y divide-[var(--border-color)]">
{#each $snippets as snippet (snippet.id)}
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-medium text-[var(--text-primary)]">{snippet.name}</h3>
{#if snippet.is_default}
<span
class="px-2 py-0.5 text-xs font-medium bg-[var(--accent-primary)]/10 text-[var(--accent-primary)] rounded"
>
Default
</span>
{/if}
<span class="text-xs text-[var(--text-tertiary)]">{snippet.category}</span>
</div>
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 font-mono">
{snippet.content}
</p>
</div>
<div
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
onclick={() => handleInsert(snippet)}
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
title="Insert this snippet"
>
Insert
</button>
<button
onclick={() => handleStartEdit(snippet)}
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
title="Edit snippet"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
{#if !snippet.is_default}
{#if showDeleteConfirm === snippet.id}
<div class="flex items-center gap-1">
<button
onclick={() => handleDelete(snippet.id)}
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Confirm
</button>
<button
onclick={() => (showDeleteConfirm = null)}
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
>
Cancel
</button>
</div>
{:else}
<button
onclick={() => (showDeleteConfirm = snippet.id)}
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
title="Delete snippet"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
-17
View File
@@ -14,7 +14,6 @@
<div class="stats-row">
<span class="stat-label">Messages:</span>
<span class="stat-value">{$formattedStats.messagesSession}</span>
<span class="stat-secondary">/ {$formattedStats.messagesTotal}</span>
</div>
<div class="stats-section">
@@ -32,11 +31,6 @@
<span class="stat-label">Output:</span>
<span class="stat-value">{$formattedStats.sessionOutputTokens}</span>
</div>
<div class="stat-row stat-highlight">
<span class="stat-label">Total:</span>
<span class="stat-value">{$formattedStats.totalTokens}</span>
<span class="stat-cost">{$formattedStats.totalCost}</span>
</div>
</div>
<div class="stats-section">
@@ -44,17 +38,14 @@
<div class="stat-row">
<span class="stat-label">Code blocks:</span>
<span class="stat-value">{$formattedStats.codeBlocksSession}</span>
<span class="stat-secondary">/ {$formattedStats.codeBlocksTotal}</span>
</div>
<div class="stat-row">
<span class="stat-label">Files edited:</span>
<span class="stat-value">{$formattedStats.filesEditedSession}</span>
<span class="stat-secondary">/ {$formattedStats.filesEditedTotal}</span>
</div>
<div class="stat-row">
<span class="stat-label">Files created:</span>
<span class="stat-value">{$formattedStats.filesCreatedSession}</span>
<span class="stat-secondary">/ {$formattedStats.filesCreatedTotal}</span>
</div>
</div>
@@ -128,14 +119,6 @@
opacity: 0.8;
}
.stat-highlight {
font-weight: 600;
color: var(--accent-primary);
margin-top: 0.25rem;
padding-top: 0.25rem;
border-top: 1px solid var(--border-color);
}
.stat-label {
color: var(--text-secondary, #9ca3af);
}
+113 -12
View File
@@ -1,9 +1,10 @@
<script lang="ts">
interface Props {
onToggleAchievements?: () => void;
onToggleCompact?: () => void;
}
const { onToggleAchievements = () => {} }: Props = $props();
const { onToggleAchievements = () => {}, onToggleCompact = () => {} }: Props = $props();
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
@@ -11,7 +12,7 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude";
import { configStore, type HikariConfig } from "$lib/stores/config";
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
import type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte";
import StatsDisplay from "./StatsDisplay.svelte";
@@ -19,6 +20,9 @@
import HelpPanel from "./HelpPanel.svelte";
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
import { achievementProgress } from "$lib/stores/achievements";
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte";
const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -33,6 +37,9 @@
let showAbout = $state(false);
let showHelp = $state(false);
let showKeyboardShortcuts = $state(false);
let showSessionHistory = $state(false);
let showGitPanel = $state(false);
let showProfile = $state(false);
const progress = $derived($achievementProgress);
let currentConfig: HikariConfig = $state({
model: null,
@@ -49,6 +56,28 @@
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
minimize_to_tray: false,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
});
let streamerModeActive = $state(false);
isStreamerMode.subscribe((value) => {
streamerModeActive = value;
});
onMount(async () => {
@@ -202,9 +231,43 @@
</div>
<div class="flex items-center gap-3">
{#if streamerModeActive}
<div
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse"
title="Streamer mode active (Ctrl+Shift+S to toggle)"
></div>
{/if}
<button
onclick={() => (showProfile = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Profile"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</button>
<button
onclick={onToggleCompact}
class="p-1 text-gray-500 icon-trans-hover"
title="Compact Mode (Ctrl+Shift+M)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
</button>
<button
onclick={toggleAchievements}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors relative"
class="p-1 text-gray-500 icon-trans-hover relative"
title="Achievements"
>
<span class="text-lg">🏆</span>
@@ -216,11 +279,37 @@
</span>
{/if}
</button>
<button
onclick={() => (showSessionHistory = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Session History"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<button
onclick={() => (showGitPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Git Panel"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
</button>
<button
onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
? 'text-[var(--accent-primary)]'
: ''}"
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
title="Usage Stats"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -234,7 +323,7 @@
</button>
<button
onclick={configStore.openSidebar}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Settings"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -254,7 +343,7 @@
</button>
<button
onclick={() => openUrl(DONATE_URL)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Support our work"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
@@ -265,7 +354,7 @@
</button>
<button
onclick={() => (showAbout = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="About Hikari Desktop"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -279,7 +368,7 @@
</button>
<button
onclick={() => (showKeyboardShortcuts = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Keyboard Shortcuts"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -299,7 +388,7 @@
</button>
<button
onclick={() => (showHelp = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Help"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -313,7 +402,7 @@
</button>
<button
onclick={() => openUrl(DISCORD_URL)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Join our Discord"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
@@ -370,3 +459,15 @@
{#if showKeyboardShortcuts}
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
{/if}
{#if showSessionHistory}
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
{/if}
{#if showGitPanel}
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
{/if}
{#if showProfile}
<ProfilePanel onClose={() => (showProfile = false)} />
{/if}
+42 -3
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate, tick } from "svelte";
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;
@@ -17,6 +19,11 @@
currentSearchQuery = value;
});
let hidePaths = false;
shouldHidePaths.subscribe((value) => {
hidePaths = value;
});
claudeStore.terminalLines.subscribe((value) => {
lines = value;
});
@@ -122,6 +129,32 @@
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);
}
});
</script>
<div
@@ -161,9 +194,15 @@
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
{#if line.type === "assistant"}
<Markdown content={line.content} searchQuery={currentSearchQuery} />
<Markdown
content={maskPaths(line.content, hidePaths)}
searchQuery={currentSearchQuery}
/>
{:else}
<HighlightedText content={line.content} searchQuery={currentSearchQuery} />
<HighlightedText
content={maskPaths(line.content, hidePaths)}
searchQuery={currentSearchQuery}
/>
{/if}
</div>
{/each}
+1 -4
View File
@@ -53,10 +53,7 @@
Current version: {updateInfo.current_version}
</p>
<div class="flex gap-2">
<button
onclick={openRelease}
class="px-3 py-1.5 bg-[var(--accent-primary)] text-white rounded text-sm hover:brightness-110 transition-all"
>
<button onclick={openRelease} class="btn-trans-gradient px-3 py-1.5 rounded text-sm">
View Release
</button>
<button
+1 -1
View File
@@ -17,7 +17,7 @@
hasQuestionPending.subscribe((pending) => {
isVisible = pending;
if (!pending) {
selectedOptions = new SvelteSet();
selectedOptions.clear();
customAnswer = "";
showCustomInput = false;
}