generated from nhcarrigan/template
feat: massive overhaul to manage costs (#103)
### 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:
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user