generated from nhcarrigan/template
1c45507cdf
### 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>
671 lines
17 KiB
Svelte
671 lines
17 KiB
Svelte
<script lang="ts">
|
|
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";
|
|
|
|
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 }}>
|
|
<div class="stats-row">
|
|
<span class="stat-label">Duration:</span>
|
|
<span class="stat-value">{$formattedStats.sessionDuration}</span>
|
|
</div>
|
|
|
|
<div class="stats-row">
|
|
<span class="stat-label">Messages:</span>
|
|
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
|
</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">
|
|
<span class="stat-label">Session:</span>
|
|
<span class="stat-value">{$formattedStats.sessionTokens}</span>
|
|
<span class="stat-cost">{$formattedStats.sessionCost}</span>
|
|
</div>
|
|
<div class="stat-row stat-detail">
|
|
<span class="stat-label">Input:</span>
|
|
<span class="stat-value">{$formattedStats.sessionInputTokens}</span>
|
|
</div>
|
|
<div class="stat-row stat-detail">
|
|
<span class="stat-label">Output:</span>
|
|
<span class="stat-value">{$formattedStats.sessionOutputTokens}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-section">
|
|
<h3>Activity</h3>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Code blocks:</span>
|
|
<span class="stat-value">{$formattedStats.codeBlocksSession}</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Files edited:</span>
|
|
<span class="stat-value">{$formattedStats.filesEditedSession}</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">Files created:</span>
|
|
<span class="stat-value">{$formattedStats.filesCreatedSession}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{#if $formattedStats.sessionToolsFormatted.length > 0}
|
|
<div class="stats-section">
|
|
<h3 class="tools-header">
|
|
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
|
|
Tools Used
|
|
<span class="toggle-icon">{showToolsBreakdown ? "â–¼" : "â–¶"}</span>
|
|
</button>
|
|
</h3>
|
|
{#if showToolsBreakdown}
|
|
<div class="tools-breakdown">
|
|
{#each $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>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.stats-display {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
padding: 0.75rem;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
font-size: 0.85rem;
|
|
box-shadow:
|
|
0 4px 6px rgba(0, 0, 0, 0.1),
|
|
0 1px 3px rgba(0, 0, 0, 0.08);
|
|
}
|
|
|
|
.stats-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.stats-section h3 {
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin: 0 0 0.25rem 0;
|
|
padding-bottom: 0.25rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.stat-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.125rem 0;
|
|
}
|
|
|
|
.stat-detail {
|
|
margin-left: 1rem;
|
|
font-size: 0.8rem;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.stat-label {
|
|
color: var(--text-secondary, #9ca3af);
|
|
}
|
|
|
|
.stat-value {
|
|
font-family: var(--font-mono, monospace);
|
|
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;
|
|
align-items: center;
|
|
padding: 0.5rem;
|
|
background: var(--bg-primary);
|
|
border-radius: 4px;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.model-label {
|
|
color: var(--text-secondary, #9ca3af);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.model-value {
|
|
font-family: var(--font-mono, monospace);
|
|
color: var(--text-primary, #e5e7eb);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.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>
|