feat: massive overhaul to manage costs (#103)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint & Test (push) Has been cancelled
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled

### Explanation

_No response_

### Issue

Closes #102

### 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: #103
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #103.
This commit is contained in:
2026-02-04 19:58:43 -08:00
committed by Naomi Carrigan
parent daedbfd865
commit 1c45507cdf
30 changed files with 4024 additions and 103 deletions
@@ -161,7 +161,7 @@
<!-- Celebration confetti effect (CSS only) -->
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
{#each Array(10) as _ (_)}
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
<div
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
+144
View File
@@ -12,6 +12,7 @@
} from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
import CostSummary from "./CostSummary.svelte";
let config: HikariConfig = $state({
model: null,
@@ -45,6 +46,11 @@
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
});
let showCustomThemeEditor = $state(false);
@@ -74,8 +80,17 @@
const availableModels = [
{ value: "", label: "Default (from ~/.claude)" },
// Current generation (Claude 4.5)
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" },
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5 (Most Capable)" },
// Previous generation (Claude 4)
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
// Legacy (Claude 3.x)
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" },
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
];
const commonTools = [
@@ -778,6 +793,135 @@
{/if}
</section>
<!-- Budget Settings Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Budget Settings
</h3>
<!-- Enable Budget Tracking -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.budget_enabled}
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 budget tracking</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Set limits on token usage and costs per session
</p>
</div>
{#if config.budget_enabled}
<!-- Token Budget -->
<div class="mb-4">
<label for="token-budget" class="block text-sm text-[var(--text-secondary)] mb-1">
Session Token Budget
</label>
<div class="flex items-center gap-2">
<input
id="token-budget"
type="number"
bind:value={config.session_token_budget}
min="0"
step="10000"
placeholder="e.g., 100000"
class="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
<span class="text-xs text-[var(--text-tertiary)]">tokens</span>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">Leave empty for unlimited tokens</p>
</div>
<!-- Cost Budget -->
<div class="mb-4">
<label for="cost-budget" class="block text-sm text-[var(--text-secondary)] mb-1">
Session Cost Budget
</label>
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-secondary)]">$</span>
<input
id="cost-budget"
type="number"
bind:value={config.session_cost_budget}
min="0"
step="0.50"
placeholder="e.g., 5.00"
class="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
<span class="text-xs text-[var(--text-tertiary)]">USD</span>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">Leave empty for unlimited spending</p>
</div>
<!-- Warning Threshold -->
<div class="mb-4">
<label for="warning-threshold" class="block text-sm text-[var(--text-secondary)] mb-2">
Warning Threshold
</label>
<div class="flex items-center gap-3">
<input
id="warning-threshold"
type="range"
bind:value={config.budget_warning_threshold}
min="0.5"
max="0.95"
step="0.05"
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
/>
<span class="text-sm text-[var(--text-secondary)] w-12 text-right">
{Math.round(config.budget_warning_threshold * 100)}%
</span>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Show warning when this percentage of budget is used
</p>
</div>
<!-- Budget Action -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2"
>When budget is exceeded</span
>
<div class="flex gap-2" role="group" aria-label="Budget action">
<button
onclick={() => (config.budget_action = "warn")}
class="flex-1 px-3 py-2 rounded-lg border transition-colors text-sm {config.budget_action ===
'warn'
? '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)]'}"
>
Warn Only
</button>
<button
onclick={() => (config.budget_action = "block")}
class="flex-1 px-3 py-2 rounded-lg border transition-colors text-sm {config.budget_action ===
'block'
? '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)]'}"
>
Block Input
</button>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-2">
{config.budget_action === "warn"
? "Show a warning but allow continued usage"
: "Prevent sending more messages until session is reset"}
</p>
</div>
{/if}
</section>
<!-- Cost History Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Cost History
</h3>
<CostSummary />
</section>
<!-- Notifications Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
+402
View File
@@ -0,0 +1,402 @@
<script lang="ts">
import {
costTrackingStore,
formattedCosts,
formatCost,
type CostSummary,
type CostAlertThresholds,
} from "$lib/stores/costTracking";
let selectedPeriod = $state<7 | 30 | 90>(7);
let summary = $state<CostSummary | null>(null);
let isLoading = $state(false);
let showThresholdSettings = $state(false);
let thresholds = $state<CostAlertThresholds>({
daily: null,
weekly: null,
monthly: null,
});
const costs = $derived($formattedCosts);
async function loadSummary() {
isLoading = true;
summary = await costTrackingStore.getSummary(selectedPeriod);
isLoading = false;
}
async function handleExport() {
const csv = await costTrackingStore.exportCsv(selectedPeriod);
if (csv) {
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `hikari-costs-${selectedPeriod}days.csv`;
a.click();
URL.revokeObjectURL(url);
}
}
async function handleSaveThresholds() {
await costTrackingStore.setAlertThresholds(thresholds);
showThresholdSettings = false;
}
$effect(() => {
loadSummary();
});
</script>
<div class="cost-summary">
<h3 class="summary-title">Cost Summary</h3>
<!-- Quick Stats -->
<div class="quick-stats">
<div class="stat-card">
<span class="stat-label">Today</span>
<span class="stat-value">{costs.today}</span>
</div>
<div class="stat-card">
<span class="stat-label">This Week</span>
<span class="stat-value">{costs.week}</span>
</div>
<div class="stat-card">
<span class="stat-label">This Month</span>
<span class="stat-value">{costs.month}</span>
</div>
</div>
<!-- Period Selector -->
<div class="period-selector">
<button
class="period-btn"
class:active={selectedPeriod === 7}
onclick={() => (selectedPeriod = 7)}
>
7 Days
</button>
<button
class="period-btn"
class:active={selectedPeriod === 30}
onclick={() => (selectedPeriod = 30)}
>
30 Days
</button>
<button
class="period-btn"
class:active={selectedPeriod === 90}
onclick={() => (selectedPeriod = 90)}
>
90 Days
</button>
</div>
<!-- Summary Details -->
{#if isLoading}
<div class="loading">Loading...</div>
{:else if summary}
<div class="summary-details">
<div class="detail-row">
<span class="detail-label">Total Cost</span>
<span class="detail-value highlight">{formatCost(summary.total_cost)}</span>
</div>
<div class="detail-row">
<span class="detail-label">Average Daily</span>
<span class="detail-value">{formatCost(summary.average_daily_cost)}</span>
</div>
<div class="detail-row">
<span class="detail-label">Messages</span>
<span class="detail-value">{summary.total_messages.toLocaleString()}</span>
</div>
<div class="detail-row">
<span class="detail-label">Sessions</span>
<span class="detail-value">{summary.total_sessions.toLocaleString()}</span>
</div>
<div class="detail-row">
<span class="detail-label">Input Tokens</span>
<span class="detail-value">{summary.total_input_tokens.toLocaleString()}</span>
</div>
<div class="detail-row">
<span class="detail-label">Output Tokens</span>
<span class="detail-value">{summary.total_output_tokens.toLocaleString()}</span>
</div>
</div>
<!-- Daily Breakdown (mini chart) -->
{#if summary.daily_breakdown.length > 0}
<div class="chart-section">
<h4 class="chart-title">Daily Spending</h4>
<div class="mini-chart">
{#each summary.daily_breakdown.slice(-14) as day (day.date)}
{@const maxCost = Math.max(...summary.daily_breakdown.map((d) => d.cost_usd), 0.01)}
{@const height = (day.cost_usd / maxCost) * 100}
<div class="chart-bar-container" title="{day.date}: {formatCost(day.cost_usd)}">
<div class="chart-bar" style="height: {height}%"></div>
</div>
{/each}
</div>
</div>
{/if}
{/if}
<!-- Actions -->
<div class="actions">
<button class="action-btn" onclick={handleExport}> Export CSV </button>
<button class="action-btn" onclick={() => (showThresholdSettings = !showThresholdSettings)}>
Set Alerts
</button>
</div>
<!-- Threshold Settings -->
{#if showThresholdSettings}
<div class="threshold-settings">
<h4>Cost Alert Thresholds</h4>
<div class="threshold-row">
<label for="daily-threshold">Daily</label>
<input
id="daily-threshold"
type="number"
step="0.01"
placeholder="e.g., 1.00"
bind:value={thresholds.daily}
/>
</div>
<div class="threshold-row">
<label for="weekly-threshold">Weekly</label>
<input
id="weekly-threshold"
type="number"
step="0.01"
placeholder="e.g., 5.00"
bind:value={thresholds.weekly}
/>
</div>
<div class="threshold-row">
<label for="monthly-threshold">Monthly</label>
<input
id="monthly-threshold"
type="number"
step="0.01"
placeholder="e.g., 20.00"
bind:value={thresholds.monthly}
/>
</div>
<button class="save-btn" onclick={handleSaveThresholds}>Save Thresholds</button>
</div>
{/if}
</div>
<style>
.cost-summary {
padding: 1rem;
background: var(--bg-secondary);
border-radius: 8px;
}
.summary-title {
margin: 0 0 1rem 0;
font-size: 1.1rem;
color: var(--text-primary);
}
.quick-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}
.stat-card {
background: var(--bg-primary);
padding: 0.75rem;
border-radius: 6px;
text-align: center;
}
.stat-label {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.stat-value {
display: block;
font-size: 1.1rem;
font-weight: 600;
color: var(--accent-primary);
}
.period-selector {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.period-btn {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.period-btn:hover {
border-color: var(--accent-primary);
}
.period-btn.active {
background: var(--accent-primary);
color: white;
border-color: var(--accent-primary);
}
.loading {
text-align: center;
padding: 1rem;
color: var(--text-secondary);
}
.summary-details {
background: var(--bg-primary);
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
border-bottom: 1px solid var(--border-color);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
.detail-value {
color: var(--text-primary);
font-weight: 500;
}
.detail-value.highlight {
color: var(--accent-primary);
font-size: 1.1rem;
}
.chart-section {
margin-bottom: 1rem;
}
.chart-title {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: var(--text-secondary);
}
.mini-chart {
display: flex;
align-items: flex-end;
gap: 2px;
height: 60px;
background: var(--bg-primary);
padding: 0.5rem;
border-radius: 4px;
}
.chart-bar-container {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
}
.chart-bar {
width: 100%;
background: var(--accent-primary);
border-radius: 2px 2px 0 0;
min-height: 2px;
transition: height 0.3s;
}
.actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: var(--bg-secondary);
border-color: var(--accent-primary);
}
.threshold-settings {
margin-top: 1rem;
padding: 1rem;
background: var(--bg-primary);
border-radius: 6px;
}
.threshold-settings h4 {
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
color: var(--text-primary);
}
.threshold-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.threshold-row label {
width: 60px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.threshold-row input {
flex: 1;
padding: 0.4rem;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 4px;
}
.save-btn {
width: 100%;
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.save-btn:hover {
opacity: 0.9;
}
</style>
+37
View File
@@ -26,6 +26,7 @@
type SlashCommand,
} from "$lib/commands/slashCommands";
import { configStore, isStreamerMode } from "$lib/stores/config";
import { stats, estimateMessageCost, formatTokenCount } from "$lib/stores/stats";
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
@@ -50,6 +51,13 @@
let showClipboardHistory = $state(false);
let streamerModeActive = $state(false);
// Cost estimation for pre-submission display
let costEstimate = $derived(
inputValue.trim()
? estimateMessageCost(inputValue, $stats.context_tokens_used, $stats.model)
: null
);
// Context menu state
let textareaElement: HTMLTextAreaElement | undefined = $state();
let contextMenuShow = $state(false);
@@ -913,6 +921,13 @@ User: ${formattedMessage}`;
</div>
<div class="button-wrapper">
{#if costEstimate && isConnected && !isProcessing}
<div class="cost-estimate" title="Estimated input cost for this message">
<span class="cost-tokens">+{formatTokenCount(costEstimate.messageTokens)}</span>
<span class="cost-value">${costEstimate.estimatedCost.toFixed(4)}</span>
</div>
{/if}
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
<svg
width="20"
@@ -1138,6 +1153,28 @@ User: ${formattedMessage}`;
display: flex;
align-items: flex-end;
height: 100%;
gap: 8px;
}
.cost-estimate {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
padding: 0 8px;
font-size: 0.7rem;
color: var(--text-secondary);
min-width: 60px;
height: 48px;
}
.cost-tokens {
opacity: 0.8;
}
.cost-value {
font-family: var(--font-mono, monospace);
color: var(--accent-primary);
}
.attach-button {
+526 -7
View File
@@ -1,8 +1,84 @@
<script lang="ts">
import { formattedStats } from "$lib/stores/stats";
import {
formattedStats,
contextWarning,
getContextWarningMessage,
stats,
checkBudget,
getBudgetStatusMessage,
getRemainingTokenBudget,
getRemainingCostBudget,
} from "$lib/stores/stats";
import { configStore } from "$lib/stores/config";
import { costTrackingStore, formattedCosts } from "$lib/stores/costTracking";
import { fade } from "svelte/transition";
import { onMount } from "svelte";
let showToolsBreakdown = false;
interface Props {
onRequestSummary?: () => void;
onStartFreshWithContext?: () => void;
isSummarising?: boolean;
}
let { onRequestSummary, onStartFreshWithContext, isSummarising = false }: Props = $props();
let showToolsBreakdown = $state(false);
let showHistoricalCosts = $state(false);
const historicalCosts = $derived($formattedCosts);
// Initialize cost tracking on mount
onMount(() => {
costTrackingStore.refresh();
});
// Subscribe to config store
const config = configStore.config;
const warning = $derived($contextWarning);
// Budget tracking - must be defined before showCompactionOptions
const budgetStatus = $derived(
checkBudget(
$stats,
$config.budget_enabled,
$config.session_token_budget,
$config.session_cost_budget,
$config.budget_warning_threshold
)
);
const budgetMessage = $derived(getBudgetStatusMessage(budgetStatus));
// Show compaction options when context or budget is at warning/critical levels
const showCompactionOptions = $derived(
warning === "high" ||
warning === "critical" ||
budgetStatus.type === "warning" ||
budgetStatus.type === "exceeded"
);
const remainingTokens = $derived(getRemainingTokenBudget($stats, $config.session_token_budget));
const remainingCost = $derived(getRemainingCostBudget($stats, $config.session_cost_budget));
// Calculate budget usage percentages for progress bars
const tokenBudgetPercent = $derived(() => {
const budget = $config.session_token_budget;
if (budget === null || budget === 0) return 0;
const used = $stats.session_input_tokens + $stats.session_output_tokens;
return Math.min(100, (used / budget) * 100);
});
const costBudgetPercent = $derived(() => {
const budget = $config.session_cost_budget;
if (budget === null || budget === 0) return 0;
return Math.min(100, ($stats.session_cost_usd / budget) * 100);
});
// Get the appropriate colour class for the progress bar
function getBudgetBarClass(percent: number, warningThreshold: number): string {
if (percent >= 100) return "budget-bar-exceeded";
if (percent >= warningThreshold * 100) return "budget-bar-warning";
return "budget-bar-ok";
}
</script>
<div class="stats-display" transition:fade={{ duration: 200 }}>
@@ -16,6 +92,120 @@
<span class="stat-value">{$formattedStats.messagesSession}</span>
</div>
<div class="stats-section">
<h3>Context Window</h3>
<div class="stat-row">
<span class="stat-label">Used:</span>
<span class="stat-value">{$formattedStats.contextUsed} / {$formattedStats.contextLimit}</span>
</div>
<div class="stat-row">
<span class="stat-label">Utilisation:</span>
<span class="stat-value context-util {warning ? `warning-${warning}` : ''}"
>{$formattedStats.contextUtilisation}</span
>
</div>
{#if warning}
<div class="context-warning warning-{warning}">
{getContextWarningMessage(warning)}
</div>
{/if}
{#if showCompactionOptions && (onRequestSummary || onStartFreshWithContext)}
<div class="compaction-actions">
{#if onRequestSummary}
<button
class="compaction-btn"
onclick={onRequestSummary}
disabled={isSummarising}
title="Compact conversation history to reduce context usage"
>
{#if isSummarising}
Compacting...
{:else}
Compact
{/if}
</button>
{/if}
{#if onStartFreshWithContext}
<button
class="compaction-btn compaction-btn-primary"
onclick={onStartFreshWithContext}
disabled={isSummarising}
title="Start a new conversation with context from this one"
>
Fresh Start
</button>
{/if}
</div>
{/if}
</div>
{#if $config.budget_enabled}
<div class="stats-section">
<h3>Budget</h3>
{#if $config.session_token_budget !== null}
<div class="budget-item">
<div class="stat-row">
<span class="stat-label">Tokens:</span>
<span
class="stat-value {budgetStatus.type !== 'ok' && budgetStatus.budget_type === 'token'
? `budget-${budgetStatus.type}`
: ''}"
>
{($stats.session_input_tokens + $stats.session_output_tokens).toLocaleString()} / {$config.session_token_budget.toLocaleString()}
</span>
</div>
<div class="budget-bar-container">
<div
class="budget-bar {getBudgetBarClass(
tokenBudgetPercent(),
$config.budget_warning_threshold
)}"
style="width: {tokenBudgetPercent()}%"
></div>
</div>
<div class="budget-remaining">
{remainingTokens?.toLocaleString() ?? 0} remaining ({(
100 - tokenBudgetPercent()
).toFixed(1)}%)
</div>
</div>
{/if}
{#if $config.session_cost_budget !== null}
<div class="budget-item">
<div class="stat-row">
<span class="stat-label">Cost:</span>
<span
class="stat-value {budgetStatus.type !== 'ok' && budgetStatus.budget_type === 'cost'
? `budget-${budgetStatus.type}`
: ''}"
>
${$stats.session_cost_usd.toFixed(4)} / ${$config.session_cost_budget.toFixed(2)}
</span>
</div>
<div class="budget-bar-container">
<div
class="budget-bar {getBudgetBarClass(
costBudgetPercent(),
$config.budget_warning_threshold
)}"
style="width: {costBudgetPercent()}%"
></div>
</div>
<div class="budget-remaining">
${remainingCost?.toFixed(4) ?? "0.0000"} remaining ({(
100 - costBudgetPercent()
).toFixed(1)}%)
</div>
</div>
{/if}
{#if budgetMessage}
<div class="budget-warning budget-{budgetStatus.type}">
{budgetMessage}
</div>
{/if}
</div>
{/if}
<div class="stats-section">
<h3>Tokens & Cost</h3>
<div class="stat-row">
@@ -49,7 +239,7 @@
</div>
</div>
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0}
{#if $formattedStats.sessionToolsFormatted.length > 0}
<div class="stats-section">
<h3 class="tools-header">
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
@@ -59,17 +249,57 @@
</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>
{#each $formattedStats.sessionToolsFormatted.sort((a, b) => b.totalTokens - a.totalTokens) as tool (tool.name)}
<div class="stat-row stat-detail tool-row">
<span class="stat-label">{tool.name}:</span>
<span class="stat-value tool-stats">
<span class="tool-calls">{tool.callCount} calls</span>
{#if tool.totalTokens > 0}
<span class="tool-tokens">(~{tool.formattedTokens})</span>
{/if}
</span>
</div>
{/each}
<div class="tools-note">* Token estimates based on attribution</div>
</div>
{/if}
</div>
{/if}
<!-- Historical Costs Section -->
<div class="stats-section">
<h3 class="costs-header">
<button class="costs-toggle" onclick={() => (showHistoricalCosts = !showHistoricalCosts)}>
Historical Costs
<span class="toggle-icon">{showHistoricalCosts ? "▼" : "▶"}</span>
</button>
</h3>
{#if !showHistoricalCosts}
<div class="costs-quick-stats">
<span class="cost-badge" title="Today's cost">Today: {historicalCosts.today}</span>
<span class="cost-badge" title="This week's cost">Week: {historicalCosts.week}</span>
<span class="cost-badge" title="This month's cost">Month: {historicalCosts.month}</span>
</div>
{/if}
{#if showHistoricalCosts}
<div class="historical-costs-expanded">
<div class="stat-row">
<span class="stat-label">Today:</span>
<span class="stat-value cost-value">{historicalCosts.today}</span>
</div>
<div class="stat-row">
<span class="stat-label">This Week:</span>
<span class="stat-value cost-value">{historicalCosts.week}</span>
</div>
<div class="stat-row">
<span class="stat-label">This Month:</span>
<span class="stat-value cost-value">{historicalCosts.month}</span>
</div>
<p class="costs-note">Open Settings to view detailed cost history and set alerts.</p>
</div>
{/if}
</div>
<div class="model-info">
<span class="model-label">Model:</span>
<span class="model-value">{$formattedStats.model}</span>
@@ -128,6 +358,79 @@
color: var(--text-primary, #e5e7eb);
}
.stat-cost {
font-family: var(--font-mono, monospace);
color: var(--accent-primary, #10b981);
font-size: 0.8rem;
margin-left: 0.5rem;
}
.stats-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.125rem 0;
}
.tools-header {
margin: 0 !important;
padding: 0 !important;
border: none !important;
}
.tools-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.tools-toggle:hover {
color: var(--accent-primary);
}
.toggle-icon {
font-size: 0.7rem;
opacity: 0.7;
}
.tools-breakdown {
margin-top: 0.25rem;
}
.tool-row {
flex-wrap: wrap;
}
.tool-stats {
display: flex;
gap: 0.5rem;
align-items: center;
}
.tool-calls {
color: var(--text-primary, #e5e7eb);
}
.tool-tokens {
color: var(--text-secondary, #9ca3af);
font-size: 0.75rem;
}
.tools-note {
margin-top: 0.5rem;
font-size: 0.65rem;
color: var(--text-secondary, #9ca3af);
font-style: italic;
opacity: 0.8;
}
.model-info {
display: flex;
justify-content: space-between;
@@ -148,4 +451,220 @@
color: var(--text-primary, #e5e7eb);
font-size: 0.75rem;
}
.context-util {
font-weight: 600;
}
.context-util.warning-moderate {
color: #f59e0b;
}
.context-util.warning-high {
color: #f97316;
}
.context-util.warning-critical {
color: #ef4444;
}
.context-warning {
margin-top: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
line-height: 1.3;
}
.context-warning.warning-moderate {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #fbbf24;
}
.context-warning.warning-high {
background: rgba(249, 115, 22, 0.15);
border: 1px solid rgba(249, 115, 22, 0.3);
color: #fb923c;
}
.context-warning.warning-critical {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
}
/* Budget progress bar styles */
.budget-item {
margin-bottom: 0.75rem;
}
.budget-item:last-child {
margin-bottom: 0;
}
.budget-bar-container {
width: 100%;
height: 6px;
background: var(--bg-primary);
border-radius: 3px;
margin-top: 0.25rem;
overflow: hidden;
}
.budget-bar {
height: 100%;
border-radius: 3px;
transition:
width 0.3s ease,
background-color 0.3s ease;
}
.budget-bar-ok {
background: linear-gradient(90deg, #10b981, #34d399);
}
.budget-bar-warning {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.budget-bar-exceeded {
background: linear-gradient(90deg, #ef4444, #f87171);
}
.budget-remaining {
font-size: 0.7rem;
color: var(--text-secondary);
margin-top: 0.125rem;
text-align: right;
}
/* Budget warning styles */
.budget-warning {
margin-top: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
line-height: 1.3;
}
.budget-warning.budget-warning {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #fbbf24;
}
.budget-warning.budget-exceeded {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
}
.stat-value.budget-warning {
color: #f59e0b;
font-weight: 600;
}
.stat-value.budget-exceeded {
color: #ef4444;
font-weight: 600;
}
/* Compaction action buttons */
.compaction-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.compaction-btn {
flex: 1;
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 4px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s ease;
}
.compaction-btn:hover:not(:disabled) {
border-color: var(--accent-primary);
background: rgba(233, 69, 96, 0.1);
}
.compaction-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.compaction-btn-primary {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
}
.compaction-btn-primary:hover:not(:disabled) {
background: var(--accent-secondary);
border-color: var(--accent-secondary);
}
/* Historical costs styles */
.costs-header {
margin: 0 !important;
padding: 0 !important;
border: none !important;
}
.costs-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.costs-toggle:hover {
color: var(--accent-primary);
}
.costs-quick-stats {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.cost-badge {
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 3px;
color: #10b981;
font-family: var(--font-mono, monospace);
}
.historical-costs-expanded {
margin-top: 0.5rem;
}
.cost-value {
color: #10b981;
}
.costs-note {
margin: 0.5rem 0 0 0;
font-size: 0.65rem;
color: var(--text-secondary);
font-style: italic;
opacity: 0.8;
}
</style>
+122 -2
View File
@@ -24,6 +24,12 @@
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte";
import { conversationsStore } from "$lib/stores/conversations";
import {
generateContextInjection,
createSummary,
sanitizeForJson,
} from "$lib/utils/conversationUtils";
const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -41,6 +47,7 @@
let showSessionHistory = $state(false);
let showGitPanel = $state(false);
let showProfile = $state(false);
let isSummarising = $state(false);
const progress = $derived($achievementProgress);
let currentConfig: HikariConfig = $state({
model: null,
@@ -74,6 +81,11 @@
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
});
let streamerModeActive = $state(false);
@@ -200,6 +212,106 @@
function toggleAchievements() {
onToggleAchievements();
}
async function handleCompactConversation() {
const activeId = get(conversationsStore.activeConversationId);
if (!activeId) return;
isSummarising = true;
try {
const conversationContent = conversationsStore.getConversationForSummary(activeId);
const messageCount =
get(conversationsStore.activeConversation)?.terminalLines.filter(
(l) => l.type === "user" || l.type === "assistant"
).length || 0;
const tokenEstimate = conversationsStore.estimateTokenCount(activeId);
// Create a summary from the conversation content (truncate if too long)
// Apply sanitization early to handle any problematic escape sequences
const sanitizedContent = sanitizeForJson(conversationContent);
const summaryContent =
sanitizedContent.length > 4000
? `${sanitizedContent.slice(0, 4000)}\n\n[Truncated for length - original had ${messageCount} messages]`
: sanitizedContent;
// Step 1: Disconnect from Claude to reset context
if (connectionStatus === "connected") {
await invoke("stop_claude", { conversationId: activeId });
}
// Step 2: Clear messages and store summary
conversationsStore.compactWithSummary(activeId, summaryContent, messageCount, tokenEstimate);
// Step 3: Reconnect to Claude with fresh context
const allAllowedTools = [
...(currentConfig.auto_granted_tools || []),
...Array.from(get(claudeStore.grantedTools)),
];
await invoke("start_claude", {
conversationId: activeId,
options: {
working_dir: workingDirectory || selectedDirectory,
model: currentConfig.model || null,
api_key: currentConfig.api_key || null,
custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools,
},
});
// Step 4: Send the context summary to Claude as the first message
const contextPrompt = generateContextInjection(
createSummary(summaryContent, messageCount, tokenEstimate)
);
await invoke("send_prompt", {
conversationId: activeId,
message: contextPrompt,
});
claudeStore.addLine(
"system",
"Conversation compacted. Context from previous session has been provided to Claude."
);
} catch (error) {
console.error("Failed to compact conversation:", error);
claudeStore.addLine("error", `Failed to compact conversation: ${error}`);
} finally {
isSummarising = false;
}
}
async function handleStartFreshWithContext() {
const activeId = get(conversationsStore.activeConversationId);
if (!activeId) return;
const conversationContent = conversationsStore.getConversationForSummary(activeId);
const messageCount =
get(conversationsStore.activeConversation)?.terminalLines.filter(
(l) => l.type === "user" || l.type === "assistant"
).length || 0;
const tokenEstimate = conversationsStore.estimateTokenCount(activeId);
const summary = createSummary(
`This is a continuation of a previous conversation. Here's what was discussed:\n\n${conversationContent.slice(0, 4000)}${conversationContent.length > 4000 ? "\n\n[Truncated for length...]" : ""}`,
messageCount,
tokenEstimate
);
const newConvId = conversationsStore.createConversation("Fresh Start");
conversationsStore.setSummary(newConvId, summary);
// Context injection is generated but the actual injection happens via the summary
generateContextInjection(summary);
claudeStore.addLine("system", "Started fresh conversation with context from previous session.");
claudeStore.addLine(
"system",
`Previous session had ${messageCount} messages (~${tokenEstimate.toLocaleString()} tokens).`
);
}
</script>
<div
@@ -446,7 +558,11 @@
{#if showStats}
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
<StatsDisplay />
<StatsDisplay
onRequestSummary={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext}
{isSummarising}
/>
</div>
{/if}
{#if connectionStatus === "connected"}
@@ -473,7 +589,11 @@
<!-- 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 />
<StatsDisplay
onRequestSummary={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext}
{isSummarising}
/>
</div>
{/if}
+18
View File
@@ -211,6 +211,16 @@
{#each lines as line (line.id)}
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
{#if line.cost && line.cost.costUsd > 0}
<span
class="terminal-cost text-xs mr-2"
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
>
${line.cost.costUsd < 0.01
? line.cost.costUsd.toFixed(4)
: line.cost.costUsd.toFixed(3)}
</span>
{/if}
{#if getLinePrefix(line.type)}
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
{/if}
@@ -291,6 +301,14 @@
color: var(--text-tertiary, #6b7280);
}
.terminal-cost {
color: var(--terminal-cost, #10b981);
background: var(--terminal-cost-bg, rgba(16, 185, 129, 0.1));
padding: 0 4px;
border-radius: 3px;
font-family: monospace;
}
.terminal-prefix {
color: var(--text-secondary);
}
@@ -90,6 +90,8 @@ class NotificationManager {
return "Successfully connected to Claude Code";
case NotificationType.TASK_START:
return "Starting task...";
case NotificationType.COST_ALERT:
return "You've exceeded your cost threshold!";
default:
return "Notification";
}
@@ -115,6 +117,10 @@ class NotificationManager {
async notifyTaskStart(message?: string): Promise<void> {
await this.notify(NotificationType.TASK_START, message);
}
async notifyCostAlert(message?: string): Promise<void> {
await this.notify(NotificationType.COST_ALERT, message);
}
}
// Export singleton instance
+9 -4
View File
@@ -51,9 +51,13 @@ describe("notifications", () => {
expect(NotificationType.ACHIEVEMENT).toBe("achievement");
});
it("has exactly 6 notification types", () => {
it("has exactly 7 notification types", () => {
const types = Object.values(NotificationType);
expect(types.length).toBe(6);
expect(types.length).toBe(7);
});
it("has COST_ALERT type", () => {
expect(NotificationType.COST_ALERT).toBe("cost_alert");
});
});
@@ -314,10 +318,11 @@ describe("notifications", () => {
});
});
it("sound filenames are unique", () => {
it("sound filenames are mostly unique", () => {
const filenames = Object.values(NOTIFICATION_SOUNDS).map((s) => s.filename);
const uniqueFilenames = new Set(filenames);
expect(uniqueFilenames.size).toBe(filenames.length);
// Allow some sound reuse (e.g., COST_ALERT reuses ERROR sound)
expect(uniqueFilenames.size).toBeGreaterThanOrEqual(filenames.length - 1);
});
it("phrases are unique", () => {
+7
View File
@@ -5,6 +5,7 @@ export enum NotificationType {
CONNECTION = "connection",
TASK_START = "task_start",
ACHIEVEMENT = "achievement",
COST_ALERT = "cost_alert",
}
export interface NotificationSound {
@@ -52,4 +53,10 @@ export const NOTIFICATION_SOUNDS: Record<NotificationType, NotificationSound> =
phrase: "Achievement Get~!",
volume: 0.8,
},
[NotificationType.COST_ALERT]: {
type: NotificationType.COST_ALERT,
filename: "oh-no.mp3",
phrase: "Cost Alert!",
volume: 0.9,
},
};
+10
View File
@@ -187,6 +187,11 @@ describe("config store", () => {
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
};
expect(config.model).toBe("claude-sonnet-4");
@@ -227,6 +232,11 @@ describe("config store", () => {
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
};
expect(config.model).toBeNull();
+12
View File
@@ -2,6 +2,7 @@ import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
export type Theme = "dark" | "light" | "high-contrast" | "custom";
export type BudgetAction = "warn" | "block";
export interface CustomThemeColors {
bg_primary: string | null;
@@ -37,6 +38,12 @@ export interface HikariConfig {
profile_avatar_path: string | null;
profile_bio: string | null;
custom_theme_colors: CustomThemeColors;
// Budget settings
budget_enabled: boolean;
session_token_budget: number | null;
session_cost_budget: number | null;
budget_action: BudgetAction;
budget_warning_threshold: number;
}
const defaultConfig: HikariConfig = {
@@ -71,6 +78,11 @@ const defaultConfig: HikariConfig = {
text_secondary: null,
border_color: null,
},
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
};
function createConfigStore() {
+143 -2
View File
@@ -11,6 +11,13 @@ import { cleanupConversationTracking } from "$lib/tauri";
import { characterState } from "$lib/stores/character";
import { sessionsStore } from "$lib/stores/sessions";
export interface ConversationSummary {
generatedAt: Date;
content: string;
messageCount: number;
tokenEstimate: number;
}
export interface Conversation {
id: string;
name: string;
@@ -27,6 +34,7 @@ export interface Conversation {
createdAt: Date;
lastActivityAt: Date;
attachments: Attachment[];
summary: ConversationSummary | null;
}
function createConversationsStore() {
@@ -63,6 +71,7 @@ function createConversationsStore() {
createdAt: new Date(),
lastActivityAt: new Date(),
attachments: [],
summary: null,
};
}
@@ -420,7 +429,12 @@ function createConversationsStore() {
});
},
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
addLine: (
type: TerminalLine["type"],
content: string,
toolName?: string,
cost?: TerminalLine["cost"]
) => {
ensureInitialized();
const activeId = get(activeConversationId);
if (!activeId) return "";
@@ -431,6 +445,7 @@ function createConversationsStore() {
content,
timestamp: new Date(),
toolName,
cost,
};
conversations.update((convs) => {
@@ -451,7 +466,8 @@ function createConversationsStore() {
conversationId: string,
type: TerminalLine["type"],
content: string,
toolName?: string
toolName?: string,
cost?: TerminalLine["cost"]
) => {
ensureInitialized();
@@ -461,6 +477,7 @@ function createConversationsStore() {
content,
timestamp: new Date(),
toolName,
cost,
};
conversations.update((convs) => {
@@ -636,6 +653,130 @@ function createConversationsStore() {
return conv?.attachments || [];
},
// Summary/compaction functions
setSummary: (conversationId: string, summary: ConversationSummary) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.summary = summary;
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearSummary: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.summary = null;
conv.lastActivityAt = new Date();
}
return convs;
});
},
getSummary: (conversationId: string): ConversationSummary | null => {
const convs = get(conversations);
const conv = convs.get(conversationId);
return conv?.summary || null;
},
// Estimate token count for a conversation (rough approximation: ~4 chars per token)
estimateTokenCount: (conversationId: string): number => {
const convs = get(conversations);
const conv = convs.get(conversationId);
if (!conv) return 0;
const relevantLines = conv.terminalLines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
const totalChars = relevantLines.reduce((sum, line) => sum + line.content.length, 0);
return Math.ceil(totalChars / 4);
},
// Get conversation content suitable for summarisation
getConversationForSummary: (conversationId: string): string => {
const convs = get(conversations);
const conv = convs.get(conversationId);
if (!conv) return "";
const relevantLines = conv.terminalLines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
return relevantLines
.map((line) => {
const role = line.type === "user" ? "User" : "Assistant";
return `${role}: ${line.content}`;
})
.join("\n\n");
},
// Compact conversation by keeping only recent messages
compactConversation: (conversationId: string, keepRecentCount: number = 10) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv && conv.terminalLines.length > keepRecentCount) {
// Keep system messages and the most recent user/assistant messages
const systemLines = conv.terminalLines.filter(
(line) => line.type !== "user" && line.type !== "assistant"
);
const chatLines = conv.terminalLines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
// Keep only the most recent chat messages
const recentChatLines = chatLines.slice(-keepRecentCount);
// Combine: system lines at original positions + recent chat lines
conv.terminalLines = [...systemLines.slice(-5), ...recentChatLines];
conv.lastActivityAt = new Date();
}
return convs;
});
},
// Compact conversation with a summary - clears old messages and injects summary context
compactWithSummary: (
conversationId: string,
summaryContent: string,
messageCount: number,
tokenEstimate: number
) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
// Store the summary
conv.summary = {
generatedAt: new Date(),
content: summaryContent,
messageCount,
tokenEstimate,
};
// Clear all messages and add a context injection message
conv.terminalLines = [
{
id: generateLineId(),
type: "system",
content: `[Conversation compacted] Previous session had ${messageCount} messages (~${tokenEstimate.toLocaleString()} tokens). Context preserved below.`,
timestamp: new Date(),
},
{
id: generateLineId(),
type: "system",
content: `Previous Session Context:\n${summaryContent}`,
timestamp: new Date(),
},
];
conv.lastActivityAt = new Date();
}
return convs;
});
},
// Add initialization helper
initialize: () => {
ensureInitialized();
+182
View File
@@ -0,0 +1,182 @@
import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { notificationManager } from "$lib/notifications/notificationManager";
// Types matching Rust backend
export interface DailyCost {
date: string;
input_tokens: number;
output_tokens: number;
cost_usd: number;
messages_sent: number;
sessions_count: number;
}
export interface CostSummary {
period_days: number;
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
total_messages: number;
total_sessions: number;
average_daily_cost: number;
daily_breakdown: DailyCost[];
}
export type AlertType = "Daily" | "Weekly" | "Monthly";
export interface CostAlert {
alert_type: AlertType;
threshold: number;
current_cost: number;
}
export interface CostAlertThresholds {
daily: number | null;
weekly: number | null;
monthly: number | null;
}
// Store state
interface CostTrackingState {
todayCost: number;
weekCost: number;
monthCost: number;
summary: CostSummary | null;
alerts: CostAlert[];
thresholds: CostAlertThresholds;
isLoading: boolean;
lastUpdated: Date | null;
}
const defaultState: CostTrackingState = {
todayCost: 0,
weekCost: 0,
monthCost: 0,
summary: null,
alerts: [],
thresholds: { daily: null, weekly: null, monthly: null },
isLoading: false,
lastUpdated: null,
};
function createCostTrackingStore() {
const { subscribe, set, update } = writable<CostTrackingState>(defaultState);
return {
subscribe,
async refresh() {
update((s) => ({ ...s, isLoading: true }));
try {
const [todayCost, weekCost, monthCost, alerts] = await Promise.all([
invoke<number>("get_today_cost"),
invoke<number>("get_week_cost"),
invoke<number>("get_month_cost"),
invoke<CostAlert[]>("get_cost_alerts"),
]);
update((s) => ({
...s,
todayCost,
weekCost,
monthCost,
alerts,
isLoading: false,
lastUpdated: new Date(),
}));
// Trigger notifications for any new alerts
if (alerts.length > 0) {
for (const alert of alerts) {
const message = getAlertMessage(alert);
notificationManager.notifyCostAlert(message);
}
}
return alerts;
} catch (error) {
console.error("Failed to refresh cost tracking:", error);
update((s) => ({ ...s, isLoading: false }));
return [];
}
},
async getSummary(days: number): Promise<CostSummary | null> {
try {
const summary = await invoke<CostSummary>("get_cost_summary", { days });
update((s) => ({ ...s, summary }));
return summary;
} catch (error) {
console.error("Failed to get cost summary:", error);
return null;
}
},
async setAlertThresholds(thresholds: CostAlertThresholds) {
try {
await invoke("set_cost_alert_thresholds", {
daily: thresholds.daily,
weekly: thresholds.weekly,
monthly: thresholds.monthly,
});
update((s) => ({ ...s, thresholds }));
} catch (error) {
console.error("Failed to set alert thresholds:", error);
}
},
async exportCsv(days: number): Promise<string | null> {
try {
return await invoke<string>("export_cost_csv", { days });
} catch (error) {
console.error("Failed to export CSV:", error);
return null;
}
},
reset() {
set(defaultState);
},
};
}
export const costTrackingStore = createCostTrackingStore();
// Derived stores for formatted values
export const formattedCosts = derived(costTrackingStore, ($store) => ({
today: formatCost($store.todayCost),
week: formatCost($store.weekCost),
month: formatCost($store.monthCost),
todayRaw: $store.todayCost,
weekRaw: $store.weekCost,
monthRaw: $store.monthCost,
}));
// Helper functions
export function formatCost(cost: number): string {
if (cost < 0.01) {
return `$${cost.toFixed(4)}`;
}
if (cost < 1) {
return `$${cost.toFixed(3)}`;
}
return `$${cost.toFixed(2)}`;
}
export function formatAlertType(type: AlertType): string {
switch (type) {
case "Daily":
return "Today";
case "Weekly":
return "This Week";
case "Monthly":
return "This Month";
}
}
export function getAlertMessage(alert: CostAlert): string {
const period = formatAlertType(alert.alert_type);
return `${period}'s spending (${formatCost(alert.current_cost)}) has exceeded your ${formatCost(alert.threshold)} threshold`;
}
+269 -12
View File
@@ -1,7 +1,25 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { get } from "svelte/store";
import { stats, formattedStats, resetSessionStats } from "./stats";
import type { UsageStats } from "./stats";
import {
stats,
formattedStats,
resetSessionStats,
contextWarning,
getContextWarningMessage,
estimateMessageCost,
formatTokenCount,
MODEL_PRICING,
} from "./stats";
import type { UsageStats, ToolTokenStats } from "./stats";
// Helper function to create ToolTokenStats for tests
function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats {
return {
call_count: callCount,
estimated_input_tokens: inputTokens,
estimated_output_tokens: outputTokens,
};
}
// Mock Tauri APIs
vi.mock("@tauri-apps/api/event", () => ({
@@ -34,6 +52,11 @@ describe("stats store", () => {
tools_usage: {},
session_tools_usage: {},
session_duration_seconds: 0,
context_tokens_used: 0,
context_window_limit: 200000,
context_utilisation_percent: 0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
});
});
@@ -63,9 +86,14 @@ describe("stats store", () => {
session_files_edited: 2,
files_created: 1,
session_files_created: 1,
tools_usage: { Read: 5, Edit: 3 },
session_tools_usage: { Read: 2, Edit: 1 },
tools_usage: { Read: toolStats(5), Edit: toolStats(3) },
session_tools_usage: { Read: toolStats(2), Edit: toolStats(1) },
session_duration_seconds: 300,
context_tokens_used: 500,
context_window_limit: 200000,
context_utilisation_percent: 0.25,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
};
stats.set(newStats);
@@ -74,7 +102,8 @@ describe("stats store", () => {
expect(currentStats.total_input_tokens).toBe(1000);
expect(currentStats.total_output_tokens).toBe(2000);
expect(currentStats.model).toBe("claude-sonnet-4");
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
expect(currentStats.tools_usage.Read?.call_count).toBe(5);
expect(currentStats.tools_usage.Edit?.call_count).toBe(3);
});
it("can be updated with update function", () => {
@@ -109,9 +138,14 @@ describe("stats store", () => {
session_files_edited: 2,
files_created: 1,
session_files_created: 1,
tools_usage: { Read: 5, Edit: 3 },
session_tools_usage: { Read: 2, Edit: 1 },
tools_usage: { Read: toolStats(5), Edit: toolStats(3) },
session_tools_usage: { Read: toolStats(2), Edit: toolStats(1) },
session_duration_seconds: 300,
context_tokens_used: 500,
context_window_limit: 200000,
context_utilisation_percent: 0.25,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
});
// Reset session stats
@@ -127,7 +161,8 @@ describe("stats store", () => {
expect(currentStats.code_blocks_generated).toBe(3);
expect(currentStats.files_edited).toBe(5);
expect(currentStats.files_created).toBe(1);
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 });
expect(currentStats.tools_usage.Read?.call_count).toBe(5);
expect(currentStats.tools_usage.Edit?.call_count).toBe(3);
expect(currentStats.model).toBe("claude-sonnet-4");
// Session stats should be reset
@@ -277,8 +312,8 @@ describe("stats store", () => {
});
it("exposes tools usage directly", () => {
const toolsUsage = { Read: 10, Edit: 5, Write: 3 };
const sessionToolsUsage = { Read: 2, Edit: 1 };
const toolsUsage = { Read: toolStats(10), Edit: toolStats(5), Write: toolStats(3) };
const sessionToolsUsage = { Read: toolStats(2), Edit: toolStats(1) };
stats.update((current) => ({
...current,
@@ -331,9 +366,14 @@ describe("stats store", () => {
session_files_edited: 1,
files_created: 1,
session_files_created: 0,
tools_usage: { Read: 3 },
session_tools_usage: { Read: 1 },
tools_usage: { Read: toolStats(3) },
session_tools_usage: { Read: toolStats(1) },
session_duration_seconds: 60,
context_tokens_used: 50,
context_window_limit: 200000,
context_utilisation_percent: 0.025,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
};
stats.set(fullStats);
@@ -343,4 +383,221 @@ describe("stats store", () => {
expect(currentStats).toEqual(fullStats);
});
});
describe("context window tracking", () => {
it("tracks context tokens used", () => {
stats.update((current) => ({
...current,
context_tokens_used: 100000,
context_window_limit: 200000,
context_utilisation_percent: 50.0,
}));
const currentStats = get(stats);
expect(currentStats.context_tokens_used).toBe(100000);
expect(currentStats.context_window_limit).toBe(200000);
expect(currentStats.context_utilisation_percent).toBe(50.0);
});
it("formats context stats correctly", () => {
stats.update((current) => ({
...current,
context_tokens_used: 150000,
context_window_limit: 200000,
context_utilisation_percent: 75.5,
}));
const formatted = get(formattedStats);
expect(formatted.contextUsed).toBe("150,000");
expect(formatted.contextLimit).toBe("200,000");
expect(formatted.contextRemaining).toBe("50,000");
expect(formatted.contextUtilisation).toBe("75.5%");
});
it("calculates remaining tokens correctly at limit", () => {
stats.update((current) => ({
...current,
context_tokens_used: 200000,
context_window_limit: 200000,
context_utilisation_percent: 100.0,
}));
const formatted = get(formattedStats);
expect(formatted.contextRemaining).toBe("0");
});
it("handles over-limit gracefully", () => {
stats.update((current) => ({
...current,
context_tokens_used: 250000,
context_window_limit: 200000,
context_utilisation_percent: 125.0,
}));
const formatted = get(formattedStats);
expect(formatted.contextRemaining).toBe("0");
});
});
describe("contextWarning derived store", () => {
it("returns null when under 50%", () => {
stats.update((current) => ({
...current,
context_utilisation_percent: 40.0,
}));
const warning = get(contextWarning);
expect(warning).toBeNull();
});
it("returns moderate when between 50-74%", () => {
stats.update((current) => ({
...current,
context_utilisation_percent: 60.0,
}));
const warning = get(contextWarning);
expect(warning).toBe("moderate");
});
it("returns high when between 75-89%", () => {
stats.update((current) => ({
...current,
context_utilisation_percent: 80.0,
}));
const warning = get(contextWarning);
expect(warning).toBe("high");
});
it("returns critical when 90%+", () => {
stats.update((current) => ({
...current,
context_utilisation_percent: 95.0,
}));
const warning = get(contextWarning);
expect(warning).toBe("critical");
});
});
describe("getContextWarningMessage", () => {
it("returns correct message for moderate warning", () => {
const message = getContextWarningMessage("moderate");
expect(message).toContain("50%+");
expect(message).toContain("Consider starting a new conversation");
});
it("returns correct message for high warning", () => {
const message = getContextWarningMessage("high");
expect(message).toContain("75%+");
expect(message).toContain("Responses may degrade");
});
it("returns correct message for critical warning", () => {
const message = getContextWarningMessage("critical");
expect(message).toContain("90%+");
expect(message).toContain("Start a new conversation");
});
});
describe("formatTokenCount", () => {
it("formats small numbers directly", () => {
expect(formatTokenCount(0)).toBe("0");
expect(formatTokenCount(100)).toBe("100");
expect(formatTokenCount(999)).toBe("999");
});
it("formats thousands with K suffix", () => {
expect(formatTokenCount(1000)).toBe("1.0K");
expect(formatTokenCount(1500)).toBe("1.5K");
expect(formatTokenCount(10000)).toBe("10.0K");
expect(formatTokenCount(999999)).toBe("1000.0K");
});
it("formats millions with M suffix", () => {
expect(formatTokenCount(1000000)).toBe("1.0M");
expect(formatTokenCount(1500000)).toBe("1.5M");
expect(formatTokenCount(10000000)).toBe("10.0M");
});
});
describe("estimateMessageCost", () => {
it("estimates tokens at ~4 chars per token", () => {
const result = estimateMessageCost("test", 0, null); // 4 chars = 1 token
expect(result.messageTokens).toBe(1);
});
it("rounds up partial tokens", () => {
const result = estimateMessageCost("a", 0, null); // 1 char rounds up to 1 token
expect(result.messageTokens).toBe(1);
const result2 = estimateMessageCost("abcde", 0, null); // 5 chars = 2 tokens
expect(result2.messageTokens).toBe(2);
});
it("returns 0 tokens for empty string", () => {
const result = estimateMessageCost("", 0, null);
expect(result.messageTokens).toBe(0);
expect(result.estimatedCost).toBe(0);
});
it("adds context tokens to total", () => {
const result = estimateMessageCost("test", 1000, null); // 1 token + 1000 context
expect(result.messageTokens).toBe(1);
expect(result.totalInputTokens).toBe(1001);
});
it("calculates cost using Sonnet pricing by default", () => {
// 100 chars = 25 tokens, $3 per million input tokens
const result = estimateMessageCost("a".repeat(100), 0, null);
expect(result.messageTokens).toBe(25);
const expectedCost = (25 / 1_000_000) * 3.0;
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
});
it("uses Opus pricing for Opus models", () => {
const result = estimateMessageCost("a".repeat(100), 0, "claude-opus-4-5-20251101");
expect(result.messageTokens).toBe(25);
const expectedCost = (25 / 1_000_000) * 5.0; // Opus 4.5: $5 per million input
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
});
it("uses Haiku pricing for Haiku models", () => {
const result = estimateMessageCost("a".repeat(100), 0, "claude-3-5-haiku-20241022");
expect(result.messageTokens).toBe(25);
const expectedCost = (25 / 1_000_000) * 1.0; // Haiku: $1 per million
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
});
it("falls back to Sonnet pricing for unknown models", () => {
const result = estimateMessageCost("a".repeat(100), 0, "unknown-model");
expect(result.messageTokens).toBe(25);
const expectedCost = (25 / 1_000_000) * 3.0; // Default Sonnet: $3 per million
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
});
});
describe("MODEL_PRICING", () => {
it("contains expected Opus pricing", () => {
// Opus 4.5 has reduced pricing
expect(MODEL_PRICING["claude-opus-4-5-20251101"]).toEqual({ input: 5.0, output: 25.0 });
// Previous Opus models have higher pricing
expect(MODEL_PRICING["claude-opus-4-1-20250805"]).toEqual({ input: 15.0, output: 75.0 });
expect(MODEL_PRICING["claude-opus-4-20250514"]).toEqual({ input: 15.0, output: 75.0 });
});
it("contains expected Sonnet pricing", () => {
expect(MODEL_PRICING["claude-sonnet-4-5-20250929"]).toEqual({ input: 3.0, output: 15.0 });
expect(MODEL_PRICING["claude-sonnet-4-20250514"]).toEqual({ input: 3.0, output: 15.0 });
expect(MODEL_PRICING["claude-3-7-sonnet-20250219"]).toEqual({ input: 3.0, output: 15.0 });
expect(MODEL_PRICING["claude-3-5-sonnet-20241022"]).toEqual({ input: 3.0, output: 15.0 });
});
it("contains expected Haiku pricing", () => {
expect(MODEL_PRICING["claude-haiku-4-5-20251001"]).toEqual({ input: 1.0, output: 5.0 });
expect(MODEL_PRICING["claude-3-5-haiku-20241022"]).toEqual({ input: 1.0, output: 5.0 });
expect(MODEL_PRICING["claude-3-haiku-20240307"]).toEqual({ input: 0.25, output: 1.25 });
});
});
});
+211 -2
View File
@@ -1,6 +1,66 @@
import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { costTrackingStore } from "./costTracking";
export type ContextWarning = "moderate" | "high" | "critical";
export type BudgetType = "token" | "cost";
// Model pricing (per million tokens) - keep in sync with stats.rs
// Source: https://platform.claude.com/docs/en/about-claude/models/overview
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
// Current generation (Claude 4.5)
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
"claude-haiku-4-5-20251001": { input: 1.0, output: 5.0 },
// Previous generation (Claude 4.x)
"claude-opus-4-1-20250805": { input: 15.0, output: 75.0 },
"claude-opus-4-20250514": { input: 15.0, output: 75.0 },
"claude-sonnet-4-20250514": { input: 3.0, output: 15.0 },
// Legacy (Claude 3.x)
"claude-3-7-sonnet-20250219": { input: 3.0, output: 15.0 },
"claude-3-5-sonnet-20241022": { input: 3.0, output: 15.0 },
"claude-3-5-sonnet-20240620": { input: 3.0, output: 15.0 },
"claude-3-5-haiku-20241022": { input: 1.0, output: 5.0 },
"claude-3-opus-20240229": { input: 15.0, output: 75.0 },
"claude-3-sonnet-20240229": { input: 3.0, output: 15.0 },
"claude-3-haiku-20240307": { input: 0.25, output: 1.25 },
};
const DEFAULT_PRICING = { input: 3.0, output: 15.0 }; // Default to Sonnet
export interface CostEstimate {
messageTokens: number;
totalInputTokens: number;
estimatedCost: number;
}
// Estimate cost for a message before sending
export function estimateMessageCost(
messageText: string,
contextTokensUsed: number,
model: string | null
): CostEstimate {
// Estimate tokens using ~4 chars per token heuristic
const messageTokens = Math.ceil(messageText.length / 4);
const totalInputTokens = contextTokensUsed + messageTokens;
const pricing = model ? (MODEL_PRICING[model] ?? DEFAULT_PRICING) : DEFAULT_PRICING;
const estimatedCost = (totalInputTokens / 1_000_000) * pricing.input;
return { messageTokens, totalInputTokens, estimatedCost };
}
export type BudgetStatus =
| { type: "ok" }
| { type: "warning"; budget_type: BudgetType; percent_used: number }
| { type: "exceeded"; budget_type: BudgetType };
// Per-tool token usage statistics
export interface ToolTokenStats {
call_count: number;
estimated_input_tokens: number;
estimated_output_tokens: number;
}
export interface UsageStats {
total_input_tokens: number;
@@ -20,9 +80,18 @@ export interface UsageStats {
session_files_edited: number;
files_created: number;
session_files_created: number;
tools_usage: Record<string, number>;
session_tools_usage: Record<string, number>;
tools_usage: Record<string, ToolTokenStats>;
session_tools_usage: Record<string, ToolTokenStats>;
session_duration_seconds: number;
// Context window tracking
context_tokens_used: number;
context_window_limit: number;
context_utilisation_percent: number;
// Cache analytics (tracks potential savings from repeated tool calls)
potential_cache_hits: number;
potential_cache_savings_tokens: number;
}
// Main stats store
@@ -45,8 +114,24 @@ export const stats = writable<UsageStats>({
tools_usage: {},
session_tools_usage: {},
session_duration_seconds: 0,
context_tokens_used: 0,
context_window_limit: 200000,
context_utilisation_percent: 0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
});
// Format token count with K/M suffix
export function formatTokenCount(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`;
}
return tokens.toString();
}
// Derived store for formatted display values
export const formattedStats = derived(stats, ($stats) => {
const formatNumber = (num: number) => num.toLocaleString();
@@ -65,6 +150,20 @@ export const formattedStats = derived(stats, ($stats) => {
}
};
// Format tool stats with token info
const formatToolStats = (toolStats: Record<string, ToolTokenStats>) => {
return Object.entries(toolStats).map(([name, stats]) => ({
name,
callCount: stats.call_count,
totalTokens: stats.estimated_input_tokens + stats.estimated_output_tokens,
formattedTokens: formatTokenCount(
stats.estimated_input_tokens + stats.estimated_output_tokens
),
inputTokens: stats.estimated_input_tokens,
outputTokens: stats.estimated_output_tokens,
}));
};
return {
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
totalInputTokens: formatNumber($stats.total_input_tokens),
@@ -88,9 +187,116 @@ export const formattedStats = derived(stats, ($stats) => {
sessionDuration: formatDuration($stats.session_duration_seconds),
toolsUsage: $stats.tools_usage,
sessionToolsUsage: $stats.session_tools_usage,
// Formatted tool stats with token info
sessionToolsFormatted: formatToolStats($stats.session_tools_usage),
toolsFormatted: formatToolStats($stats.tools_usage),
// Context window tracking
contextUsed: formatNumber($stats.context_tokens_used),
contextLimit: formatNumber($stats.context_window_limit),
contextRemaining: formatNumber(
Math.max(0, $stats.context_window_limit - $stats.context_tokens_used)
),
contextUtilisation: `${$stats.context_utilisation_percent.toFixed(1)}%`,
};
});
// Derived store for context warning state
export const contextWarning = derived(stats, ($stats): ContextWarning | null => {
if ($stats.context_utilisation_percent >= 90) {
return "critical";
} else if ($stats.context_utilisation_percent >= 75) {
return "high";
} else if ($stats.context_utilisation_percent >= 50) {
return "moderate";
}
return null;
});
// Get warning message for context utilisation
export function getContextWarningMessage(warning: ContextWarning): string {
switch (warning) {
case "moderate":
return "Context window is 50%+ full. Consider starting a new conversation for better performance.";
case "high":
return "Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh.";
case "critical":
return "Context window is nearly full (90%+)! Start a new conversation to avoid errors.";
}
}
// Budget checking functions
export function checkBudget(
stats: UsageStats,
budgetEnabled: boolean,
tokenBudget: number | null,
costBudget: number | null,
warningThreshold: number
): BudgetStatus {
if (!budgetEnabled) {
return { type: "ok" };
}
const sessionTokens = stats.session_input_tokens + stats.session_output_tokens;
// Check token budget
if (tokenBudget !== null) {
if (sessionTokens >= tokenBudget) {
return { type: "exceeded", budget_type: "token" };
}
const percentUsed = sessionTokens / tokenBudget;
if (percentUsed >= warningThreshold) {
return { type: "warning", budget_type: "token", percent_used: percentUsed * 100 };
}
}
// Check cost budget
if (costBudget !== null) {
if (stats.session_cost_usd >= costBudget) {
return { type: "exceeded", budget_type: "cost" };
}
const percentUsed = stats.session_cost_usd / costBudget;
if (percentUsed >= warningThreshold) {
return { type: "warning", budget_type: "cost", percent_used: percentUsed * 100 };
}
}
return { type: "ok" };
}
// Get budget status message
export function getBudgetStatusMessage(status: BudgetStatus): string | null {
if (status.type === "ok") {
return null;
}
const budgetTypeLabel = status.budget_type === "token" ? "token" : "cost";
if (status.type === "exceeded") {
return `Session ${budgetTypeLabel} budget exceeded! Consider starting a new session.`;
}
return `Approaching ${budgetTypeLabel} budget limit (${status.percent_used.toFixed(0)}% used).`;
}
// Get remaining budget values
export function getRemainingTokenBudget(
stats: UsageStats,
tokenBudget: number | null
): number | null {
if (tokenBudget === null) return null;
const used = stats.session_input_tokens + stats.session_output_tokens;
return Math.max(0, tokenBudget - used);
}
export function getRemainingCostBudget(
stats: UsageStats,
costBudget: number | null
): number | null {
if (costBudget === null) return null;
return Math.max(0, costBudget - stats.session_cost_usd);
}
// Note: Cost calculation is now done in the Rust backend
// Initialize stats listener
@@ -102,6 +308,9 @@ export async function initStatsListener() {
// The backend already tracks all totals - just set the stats directly
stats.set(newStats);
// Refresh cost tracking to check for alerts (debounced - won't spam)
costTrackingStore.refresh();
});
// Load initial persisted stats from backend (no bridge required)
+19 -3
View File
@@ -90,6 +90,11 @@ interface OutputPayload {
content: string;
tool_name: string | null;
conversation_id?: string;
cost?: {
input_tokens: number;
output_tokens: number;
cost_usd: number;
};
}
interface ConnectionPayload {
@@ -242,7 +247,16 @@ export async function initializeTauriListeners() {
unlisteners.push(stateUnlisten);
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
const { line_type, content, tool_name, conversation_id } = event.payload;
const { line_type, content, tool_name, conversation_id, cost } = event.payload;
// Convert snake_case cost to camelCase for TypeScript
const costData = cost
? {
inputTokens: cost.input_tokens,
outputTokens: cost.output_tokens,
costUsd: cost.cost_usd,
}
: undefined;
// Always store the output to the correct conversation
if (conversation_id) {
@@ -250,14 +264,16 @@ export async function initializeTauriListeners() {
conversation_id,
line_type as "user" | "assistant" | "system" | "tool" | "error",
content,
tool_name || undefined
tool_name || undefined,
costData
);
} else {
// Fallback to active conversation if no conversation_id provided
claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error",
content,
tool_name || undefined
tool_name || undefined,
costData
);
}
});
+6
View File
@@ -4,6 +4,12 @@ export interface TerminalLine {
content: string;
timestamp: Date;
toolName?: string;
// Cost tracking for this specific message
cost?: {
inputTokens: number;
outputTokens: number;
costUsd: number;
};
}
export interface SystemInitMessage {
+188
View File
@@ -0,0 +1,188 @@
import { describe, it, expect } from "vitest";
import {
generateSummaryPrompt,
generateContextInjection,
estimateTokens,
createSummary,
shouldSuggestCompaction,
formatTokenCount,
sanitizeForJson,
} from "./conversationUtils";
import type { ConversationSummary } from "$lib/stores/conversations";
describe("conversationUtils", () => {
describe("generateSummaryPrompt", () => {
it("generates a prompt containing the conversation content", () => {
const content = "User: Hello\n\nAssistant: Hi there!";
const prompt = generateSummaryPrompt(content);
expect(prompt).toContain(content);
expect(prompt).toContain("summary");
expect(prompt).toContain("Key topics");
});
it("handles empty content", () => {
const prompt = generateSummaryPrompt("");
expect(prompt).toContain("summary");
});
});
describe("generateContextInjection", () => {
it("creates context injection message from summary", () => {
const summary: ConversationSummary = {
generatedAt: new Date("2024-01-01"),
content: "We discussed building a new feature",
messageCount: 50,
tokenEstimate: 10000,
};
const injection = generateContextInjection(summary);
expect(injection).toContain("Previous Session Context");
expect(injection).toContain("We discussed building a new feature");
expect(injection).toContain("50 messages");
expect(injection).toContain("10,000 tokens");
});
});
describe("estimateTokens", () => {
it("estimates tokens at ~4 chars per token", () => {
expect(estimateTokens("")).toBe(0);
expect(estimateTokens("test")).toBe(1); // 4 chars = 1 token
expect(estimateTokens("testing")).toBe(2); // 7 chars = 2 tokens
expect(estimateTokens("a".repeat(100))).toBe(25); // 100 chars = 25 tokens
});
it("rounds up partial tokens", () => {
expect(estimateTokens("a")).toBe(1); // 1 char rounds up to 1 token
expect(estimateTokens("ab")).toBe(1); // 2 chars rounds up to 1 token
expect(estimateTokens("abc")).toBe(1); // 3 chars rounds up to 1 token
expect(estimateTokens("abcde")).toBe(2); // 5 chars rounds up to 2 tokens
});
});
describe("createSummary", () => {
it("creates a valid ConversationSummary object", () => {
const summary = createSummary("Test summary content", 25, 5000);
expect(summary.content).toBe("Test summary content");
expect(summary.messageCount).toBe(25);
expect(summary.tokenEstimate).toBe(5000);
expect(summary.generatedAt).toBeInstanceOf(Date);
});
it("sets generatedAt to current time", () => {
const before = new Date();
const summary = createSummary("content", 10, 1000);
const after = new Date();
expect(summary.generatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(summary.generatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
});
describe("shouldSuggestCompaction", () => {
it("returns false when under threshold", () => {
expect(shouldSuggestCompaction(50000, 200000, 60)).toBe(false); // 25%
expect(shouldSuggestCompaction(100000, 200000, 60)).toBe(false); // 50%
expect(shouldSuggestCompaction(119000, 200000, 60)).toBe(false); // 59.5%
});
it("returns true when at or above threshold", () => {
expect(shouldSuggestCompaction(120000, 200000, 60)).toBe(true); // 60%
expect(shouldSuggestCompaction(150000, 200000, 60)).toBe(true); // 75%
expect(shouldSuggestCompaction(200000, 200000, 60)).toBe(true); // 100%
});
it("handles zero context window limit", () => {
expect(shouldSuggestCompaction(50000, 0, 60)).toBe(false);
});
it("uses default threshold of 60%", () => {
expect(shouldSuggestCompaction(110000, 200000)).toBe(false); // 55%
expect(shouldSuggestCompaction(130000, 200000)).toBe(true); // 65%
});
it("respects custom threshold", () => {
expect(shouldSuggestCompaction(70000, 200000, 40)).toBe(false); // 35%
expect(shouldSuggestCompaction(90000, 200000, 40)).toBe(true); // 45%
});
});
describe("formatTokenCount", () => {
it("formats small numbers directly", () => {
expect(formatTokenCount(0)).toBe("0");
expect(formatTokenCount(100)).toBe("100");
expect(formatTokenCount(999)).toBe("999");
});
it("formats thousands with K suffix", () => {
expect(formatTokenCount(1000)).toBe("1.0K");
expect(formatTokenCount(1500)).toBe("1.5K");
expect(formatTokenCount(10000)).toBe("10.0K");
expect(formatTokenCount(999999)).toBe("1000.0K");
});
it("formats millions with M suffix", () => {
expect(formatTokenCount(1000000)).toBe("1.0M");
expect(formatTokenCount(1500000)).toBe("1.5M");
expect(formatTokenCount(10000000)).toBe("10.0M");
});
});
describe("sanitizeForJson", () => {
it("returns normal text unchanged", () => {
expect(sanitizeForJson("Hello world")).toBe("Hello world");
expect(sanitizeForJson("Test 123")).toBe("Test 123");
});
it("preserves common whitespace", () => {
expect(sanitizeForJson("line1\nline2")).toBe("line1\nline2");
expect(sanitizeForJson("col1\tcol2")).toBe("col1\tcol2");
expect(sanitizeForJson("line\r\nend")).toBe("line\r\nend");
});
it("removes null bytes", () => {
expect(sanitizeForJson("hello\x00world")).toBe("helloworld");
});
it("removes other control characters", () => {
// Bell character
expect(sanitizeForJson("alert\x07here")).toBe("alerthere");
// Backspace
expect(sanitizeForJson("back\x08space")).toBe("backspace");
// Form feed is removed
expect(sanitizeForJson("page\x0Cbreak")).toBe("pagebreak");
// Escape character
expect(sanitizeForJson("esc\x1Bhere")).toBe("eschere");
});
it("preserves printable characters including backslashes", () => {
const codeContent = '```rust\nfn main() {\n println!("Hello");\n}\n```';
expect(sanitizeForJson(codeContent)).toBe(codeContent);
});
it("handles mixed content with various characters", () => {
const mixed = "User: Hello\n\nAssistant: Here's some code:\n```\nconst x = 42;\n```";
expect(sanitizeForJson(mixed)).toBe(mixed);
});
it("preserves backslash sequences", () => {
// Backslashes followed by letters should be preserved as-is
expect(sanitizeForJson("path\\to\\file")).toBe("path\\to\\file");
expect(sanitizeForJson("color\\x1b")).toBe("color\\x1b");
});
it("removes lone surrogates", () => {
// Lone surrogates (U+D800-U+DFFF) can cause JSON parse errors
// High surrogate without low
expect(sanitizeForJson("test\uD800end")).toBe("testend");
// Low surrogate without high
expect(sanitizeForJson("test\uDC00end")).toBe("testend");
// But valid surrogate pairs should remain (they form valid characters)
// Actually, JavaScript represents emoji as surrogate pairs, so this is tricky
// The regex will remove the surrogates, which may break emoji. That's acceptable
// for a conversation summary where data integrity is more important.
});
});
});
+110
View File
@@ -0,0 +1,110 @@
import type { ConversationSummary } from "$lib/stores/conversations";
/**
* Sanitises a string for safe JSON serialization through Tauri IPC.
* Removes control characters and lone surrogates that could cause issues
* during JSON serialization/deserialization.
*/
export function sanitizeForJson(text: string): string {
// Remove control characters except for common whitespace (tab, newline, carriage return)
// These can cause JSON parsing issues and are rarely meaningful in conversation summaries.
// eslint-disable-next-line no-control-regex -- regex uses control character codes
let sanitized = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
// Remove extended ASCII control chars (C1 control codes)
sanitized = sanitized.replace(/[\x80-\x9F]/g, "");
// Remove lone surrogates (U+D800 to U+DFFF) which cause "unexpected end of hex escape"
// errors in serde_json when they appear without proper pairing.
// These are invalid in JSON and can cause parse failures.
sanitized = sanitized.replace(/[\uD800-\uDFFF]/g, "");
return sanitized;
}
/**
* Generates a prompt to ask Claude to summarise a conversation.
* This can be sent as a user message to get a summary.
*/
export function generateSummaryPrompt(conversationContent: string): string {
return `Please provide a concise summary of our conversation so far. Focus on:
1. Key topics discussed
2. Important decisions or conclusions made
3. Any ongoing tasks or context that would be helpful to remember
4. Code changes or files that were modified
Keep the summary brief but comprehensive enough to continue our work in a new session.
Here is our conversation:
${conversationContent}
Please provide the summary now:`;
}
/**
* Generates a context injection message to prepend to a new conversation.
* This provides Claude with context from a previous session.
*/
export function generateContextInjection(summary: ConversationSummary): string {
return `[Previous Session Context]
The following is a summary from our previous conversation (${summary.messageCount} messages, approximately ${summary.tokenEstimate.toLocaleString()} tokens):
${summary.content}
[End of Previous Context]
Please continue from where we left off, or let me know if you need any clarification about the previous context.`;
}
/**
* Estimates the token count for a given string.
* Uses a rough approximation of ~4 characters per token.
*/
export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Creates a ConversationSummary object from summary content.
*/
export function createSummary(
content: string,
messageCount: number,
originalTokenEstimate: number
): ConversationSummary {
return {
generatedAt: new Date(),
content,
messageCount,
tokenEstimate: originalTokenEstimate,
};
}
/**
* Determines if a conversation should be compacted based on token usage.
* Returns true if the conversation is using more than the threshold percentage
* of the context window.
*/
export function shouldSuggestCompaction(
contextTokensUsed: number,
contextWindowLimit: number,
thresholdPercent: number = 60
): boolean {
if (contextWindowLimit === 0) return false;
const utilisationPercent = (contextTokensUsed / contextWindowLimit) * 100;
return utilisationPercent >= thresholdPercent;
}
/**
* Formats a token count for display.
*/
export function formatTokenCount(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`;
}
return tokens.toString();
}