feat: stats and achievements (#45)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
CI / Lint & Test (push) Successful in 14m11s
CI / Build Linux (push) Successful in 16m47s
CI / Build Windows (cross-compile) (push) Successful in 26m56s

### Explanation

_No response_

### Issue

Closes #39

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #45
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #45.
This commit is contained in:
2026-01-19 20:51:53 -08:00
committed by Naomi Carrigan
parent a8f98406e1
commit 70fcaa8650
24 changed files with 2995 additions and 19 deletions
@@ -0,0 +1,202 @@
<script lang="ts">
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { listen } from "@tauri-apps/api/event";
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
let achievements = $state<AchievementUnlockedEvent[]>([]);
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
let showNotification = $state(false);
onMount(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
achievements.push(event.payload);
if (!showNotification) {
showNext();
}
});
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
});
function showNext() {
if (achievements.length > 0) {
currentAchievement = achievements.shift() || null;
showNotification = true;
// Auto-hide after 5 seconds
setTimeout(() => {
showNotification = false;
// Show next achievement after animation completes
setTimeout(() => showNext(), 300);
}, 5000);
}
}
function dismiss() {
showNotification = false;
// Show next achievement after animation completes
setTimeout(() => showNext(), 300);
}
function getRarityColor(rarity: string): string {
switch (rarity) {
case "legendary":
return "from-yellow-400 to-orange-500";
case "epic":
return "from-purple-400 to-pink-500";
case "rare":
return "from-blue-400 to-indigo-500";
default:
return "from-green-400 to-emerald-500";
}
}
function getAchievementRarity(id: string): string {
// Determine rarity based on achievement ID
if (id === "TokenMaster") return "legendary";
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
if (
[
"BlossomingCoder",
"CodeWizard",
"MasterBuilder",
"EnduranceChamp",
"DeepDive",
"CreativeCoder",
].includes(id)
)
return "rare";
return "common";
}
</script>
{#if showNotification && currentAchievement}
<div
class="fixed top-20 right-4 z-50 max-w-sm"
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
out:fade={{ duration: 300 }}
>
<!-- Backdrop with animated gradient border -->
<div class="relative p-[2px] rounded-lg overflow-hidden">
<!-- Animated gradient border -->
<div
class="absolute inset-0 bg-gradient-to-r {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} animate-pulse"
></div>
<!-- Main notification content -->
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
<button
onclick={dismiss}
onkeydown={(e) => e.key === "Enter" && dismiss()}
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
aria-label="Dismiss notification"
>
<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"
></path>
</svg>
</button>
<div class="flex items-start gap-4">
<!-- Icon with animated sparkles -->
<div class="relative flex-shrink-0">
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
<!-- Sparkle animations -->
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping"></div>
<div
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
>
</div>
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">
</div>
</div>
<!-- Text content -->
<div class="flex-1 min-w-0 pt-1">
<h3
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
>
Achievement Unlocked!
</h3>
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
{currentAchievement.achievement.name}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{currentAchievement.achievement.description}
</p>
<!-- Rarity badge -->
<div class="mt-2 inline-flex items-center">
<span
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} text-white capitalize"
>
{getAchievementRarity(currentAchievement.achievement.id)}
</span>
</div>
</div>
</div>
<!-- Celebration confetti effect (CSS only) -->
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
{#each Array(10) as _ (_)}
<div
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} rounded-full animate-fall"
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
2}s; animation-duration: {2 + Math.random() * 2}s;"
></div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<style>
@keyframes fall {
0% {
transform: translateY(-20px) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(400px) rotate(720deg);
opacity: 0;
}
}
.animate-fall {
animation: fall linear infinite;
}
.animation-delay-200 {
animation-delay: 200ms;
}
.animation-delay-400 {
animation-delay: 400ms;
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { quintOut } from "svelte/easing";
import {
achievementsStore,
achievementProgress,
achievementCategories,
} from "$lib/stores/achievements";
import type { Achievement } from "$lib/types/achievements";
interface Props {
isOpen: boolean;
onClose?: () => void;
}
const { isOpen = $bindable(false), onClose }: Props = $props();
let selectedCategory = $state<string | null>(null);
const achievementsState = $derived($achievementsStore);
const progress = $derived($achievementProgress);
function getRarityColor(rarity: string): string {
switch (rarity) {
case "legendary":
return "text-yellow-500 dark:text-yellow-400";
case "epic":
return "text-purple-500 dark:text-purple-400";
case "rare":
return "text-blue-500 dark:text-blue-400";
default:
return "text-green-500 dark:text-green-400";
}
}
function getRarityBg(rarity: string): string {
switch (rarity) {
case "legendary":
return "bg-yellow-500/10";
case "epic":
return "bg-purple-500/10";
case "rare":
return "bg-blue-500/10";
default:
return "bg-green-500/10";
}
}
function formatDate(date: Date | undefined): string {
if (!date) return "";
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function getAchievementsForCategory(categoryIds: string[]): Achievement[] {
return categoryIds
.map(
(id) => achievementsState.achievements[id as keyof typeof achievementsState.achievements]
)
.filter(Boolean);
}
</script>
<!-- Achievements panel -->
{#if isOpen}
<div
class="fixed inset-0 bg-black/50 z-40"
onclick={onClose}
onkeydown={(e) => e.key === "Escape" && onClose?.()}
role="button"
tabindex="-1"
aria-label="Close achievements panel"
transition:slide={{ duration: 300, easing: quintOut }}
></div>
<div
class="fixed left-0 top-0 h-full w-96 bg-[var(--bg-primary)] border-r border-[var(--border-color)]
shadow-2xl z-50 flex flex-col"
transition:slide={{ duration: 300, easing: quintOut, axis: "x" }}
>
<!-- Header -->
<div class="p-6 border-b border-[var(--border-color)]">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-[var(--text-primary)]">Achievements</h2>
<button
onclick={onClose}
onkeydown={(e) => e.key === "Enter" && onClose?.()}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label="Close achievements panel"
>
<svg class="w-6 h-6" 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"
></path>
</svg>
</button>
</div>
<!-- Overall progress -->
<div class="mt-4">
<div
class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-2"
>
<span>{progress.unlocked} / {progress.total} Unlocked</span>
<span>{progress.percentage}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] h-2 rounded-full transition-all duration-500"
style="width: {progress.percentage}%"
></div>
</div>
</div>
</div>
<!-- Categories -->
<div class="flex-1 overflow-y-auto">
{#each achievementCategories as category (category.name)}
{@const achievements = getAchievementsForCategory(category.ids)}
{@const unlockedCount = achievements.filter((a) => a.unlocked).length}
<div class="border-b border-[var(--border-color)]">
<button
onclick={() =>
(selectedCategory = selectedCategory === category.name ? null : category.name)}
onkeydown={(e) =>
e.key === "Enter" &&
(selectedCategory = selectedCategory === category.name ? null : category.name)}
class="w-full p-4 text-left hover:bg-[var(--bg-secondary)] transition-colors"
>
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-[var(--text-primary)]">{category.name}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{category.description}</p>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{unlockedCount} / {achievements.length}
</span>
<svg
class="w-5 h-5 transition-transform {selectedCategory === category.name
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
></path>
</svg>
</div>
</div>
</button>
{#if selectedCategory === category.name}
<div class="p-4 space-y-3" transition:slide={{ duration: 200, easing: quintOut }}>
{#each achievements as achievement (achievement.id)}
<div
class="p-3 rounded-lg border {achievement.unlocked
? 'border-[var(--border-color)] bg-[var(--bg-secondary)]'
: 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 opacity-50'}"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="text-3xl flex-shrink-0 {achievement.unlocked ? '' : 'grayscale'}">
{achievement.icon}
</div>
<!-- Details -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h4 class="font-semibold text-[var(--text-primary)]">
{achievement.name}
</h4>
<span
class="text-xs px-2 py-0.5 rounded-full {getRarityBg(
achievement.rarity
)} {getRarityColor(achievement.rarity)} capitalize"
>
{achievement.rarity}
</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{achievement.description}
</p>
{#if achievement.unlocked && achievement.unlockedAt}
<p class="text-xs text-gray-500 dark:text-gray-500 mt-2">
Unlocked {formatDate(achievement.unlockedAt)}
</p>
{:else if achievement.maxProgress && achievement.progress !== undefined}
<!-- Progress bar for locked achievements -->
<div class="mt-2">
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Progress</span>
<span>{achievement.progress} / {achievement.maxProgress}</span>
</div>
<div class="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5">
<div
class="bg-gray-500 h-1.5 rounded-full transition-all duration-300"
style="width: {Math.min(
(achievement.progress / achievement.maxProgress) * 100,
100
)}%"
></div>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<!-- Footer with last unlocked -->
{#if achievementsState.lastUnlocked}
<div class="p-4 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Last Unlocked:</p>
<div class="flex items-center gap-2">
<span class="text-xl">{achievementsState.lastUnlocked.icon}</span>
<div>
<p class="font-semibold text-[var(--text-primary)]">
{achievementsState.lastUnlocked.name}
</p>
<p class="text-xs text-gray-500">
{formatDate(achievementsState.lastUnlocked.unlockedAt)}
</p>
</div>
</div>
</div>
{/if}
</div>
{/if}
<style>
/* Custom scrollbar for achievement list */
:global(.overflow-y-auto::-webkit-scrollbar) {
width: 8px;
}
:global(.overflow-y-auto::-webkit-scrollbar-track) {
background: var(--bg-secondary);
}
:global(.overflow-y-auto::-webkit-scrollbar-thumb) {
background: var(--border-color);
border-radius: 4px;
}
:global(.overflow-y-auto::-webkit-scrollbar-thumb:hover) {
background: var(--accent-primary);
}
</style>
+1 -1
View File
@@ -54,7 +54,7 @@
disabled={!isConnected || isSubmitting}
rows={1}
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-white placeholder-gray-500 resize-none
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
transition-all duration-200"
+168
View File
@@ -0,0 +1,168 @@
<script lang="ts">
import { formattedStats } from "$lib/stores/stats";
import { fade } from "svelte/transition";
let showToolsBreakdown = false;
</script>
<div class="stats-display" transition:fade={{ duration: 200 }}>
<div class="stats-row">
<span class="stat-label">Duration:</span>
<span class="stat-value">{$formattedStats.sessionDuration}</span>
</div>
<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">
<h3>Tokens & Cost</h3>
<div class="stat-row">
<span class="stat-label">Session:</span>
<span class="stat-value">{$formattedStats.sessionTokens}</span>
<span class="stat-cost">{$formattedStats.sessionCost}</span>
</div>
<div class="stat-row stat-detail">
<span class="stat-label">Input:</span>
<span class="stat-value">{$formattedStats.sessionInputTokens}</span>
</div>
<div class="stat-row stat-detail">
<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">
<h3>Activity</h3>
<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>
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0}
<div class="stats-section">
<h3 class="tools-header">
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
Tools Used
<span class="toggle-icon">{showToolsBreakdown ? "▼" : "▶"}</span>
</button>
</h3>
{#if showToolsBreakdown}
<div class="tools-breakdown">
{#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count] (tool)}
<div class="stat-row stat-detail">
<span class="stat-label">{tool}:</span>
<span class="stat-value">{count}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<div class="model-info">
<span class="model-label">Model:</span>
<span class="model-value">{$formattedStats.model}</span>
</div>
</div>
<style>
.stats-display {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 0.85rem;
box-shadow:
0 4px 6px rgba(0, 0, 0, 0.1),
0 1px 3px rgba(0, 0, 0, 0.08);
}
.stats-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stats-section h3 {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.25rem 0;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--border-color);
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.125rem 0;
}
.stat-detail {
margin-left: 1rem;
font-size: 0.8rem;
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);
}
.stat-value {
font-family: var(--font-mono, monospace);
color: var(--text-primary, #e5e7eb);
}
.model-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: var(--bg-primary);
border-radius: 4px;
font-size: 0.8rem;
}
.model-label {
color: var(--text-secondary, #9ca3af);
font-weight: 600;
}
.model-value {
font-family: var(--font-mono, monospace);
color: var(--text-primary, #e5e7eb);
font-size: 0.75rem;
}
</style>
+59
View File
@@ -1,4 +1,10 @@
<script lang="ts">
interface Props {
onToggleAchievements?: () => void;
}
const { onToggleAchievements = () => {} }: Props = $props();
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { open } from "@tauri-apps/plugin-dialog";
@@ -7,6 +13,8 @@
import { configStore, type HikariConfig } from "$lib/stores/config";
import type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte";
import StatsDisplay from "./StatsDisplay.svelte";
import { achievementProgress } from "$lib/stores/achievements";
const DISCORD_URL = "https://chat.nhcarrigan.com";
@@ -16,6 +24,8 @@
let isConnecting = $state(false);
let grantedToolsList: string[] = $state([]);
let appVersion = $state("");
let showStats = $state(false);
const progress = $derived($achievementProgress);
let currentConfig: HikariConfig = $state({
model: null,
api_key: null,
@@ -126,6 +136,10 @@
return "Disconnected";
}
}
function toggleAchievements() {
onToggleAchievements();
}
</script>
<div
@@ -167,6 +181,36 @@
</div>
<div class="flex items-center gap-3">
<button
onclick={toggleAchievements}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors relative"
title="Achievements"
>
<span class="text-lg">🏆</span>
{#if progress.unlocked > 0}
<span
class="absolute -top-1 -right-1 bg-[var(--accent-primary)] text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]"
>
{progress.unlocked}
</span>
{/if}
</button>
<button
onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
? 'text-[var(--accent-primary)]'
: ''}"
title="Usage Stats"
>
<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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zM13 19v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2h2a2 2 0 002-2zM21 19V8a2 2 0 00-2-2h-2a2 2 0 00-2 2v11a2 2 0 002 2h2a2 2 0 002-2z"
/>
</svg>
</button>
<button
onclick={configStore.openSidebar}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
@@ -201,6 +245,12 @@
{#if appVersion}
<span class="text-xs text-gray-600">v{appVersion}</span>
{/if}
{#if showStats}
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
<StatsDisplay />
</div>
{/if}
{#if connectionStatus === "connected"}
<button
onclick={handleDisconnect}
@@ -219,3 +269,12 @@
{/if}
</div>
</div>
{#if showStats}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
<div class="fixed top-14 right-4 z-50">
<StatsDisplay />
</div>
{/if}
+58 -11
View File
@@ -25,17 +25,17 @@
function getLineClass(type: string): string {
switch (type) {
case "user":
return "text-cyan-400";
return "terminal-user";
case "assistant":
return "text-gray-100";
return "terminal-assistant";
case "system":
return "text-gray-500 italic";
return "terminal-system italic";
case "tool":
return "text-purple-400";
return "terminal-tool";
case "error":
return "text-red-400";
return "terminal-error";
default:
return "text-gray-300";
return "terminal-default";
}
}
@@ -75,7 +75,7 @@
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
</div>
<span class="text-sm text-gray-400 ml-2">Terminal</span>
<span class="text-sm terminal-header-text ml-2">Terminal</span>
</div>
<div
@@ -84,16 +84,18 @@
class="terminal-content h-[calc(100%-40px)] overflow-y-auto p-4 font-mono text-sm"
>
{#if lines.length === 0}
<div class="text-gray-500 italic">Waiting for Claude... Type a message below to start!</div>
<div class="terminal-waiting italic">
Waiting for Claude... Type a message below to start!
</div>
{:else}
{#each lines as line (line.id)}
<div class="terminal-line mb-2 {getLineClass(line.type)}">
<span class="text-gray-600 text-xs mr-2">{formatTime(line.timestamp)}</span>
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
{#if getLinePrefix(line.type)}
<span class="text-gray-500 mr-2">{getLinePrefix(line.type)}</span>
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
{/if}
{#if line.toolName}
<span class="text-purple-300 mr-2">[{line.toolName}]</span>
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
<span class="whitespace-pre-wrap">{line.content}</span>
</div>
@@ -107,4 +109,49 @@
scrollbar-width: thin;
scrollbar-color: var(--border-color) var(--bg-terminal);
}
/* Terminal text colors that adapt to theme */
.terminal-user {
color: var(--terminal-user, #22d3ee);
}
.terminal-assistant {
color: var(--text-primary);
}
.terminal-system {
color: var(--text-secondary);
}
.terminal-tool {
color: var(--terminal-tool, #c084fc);
}
.terminal-error {
color: var(--terminal-error, #f87171);
}
.terminal-default {
color: var(--text-primary);
}
.terminal-timestamp {
color: var(--text-tertiary, #6b7280);
}
.terminal-prefix {
color: var(--text-secondary);
}
.terminal-tool-name {
color: var(--terminal-tool-name, #ddd6fe);
}
.terminal-waiting {
color: var(--text-secondary);
}
.terminal-header-text {
color: var(--text-secondary);
}
</style>