@benzsiangco/jarvis 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/cli.js +478 -347
  2. package/dist/electron/main.js +160 -0
  3. package/dist/electron/preload.js +19 -0
  4. package/package.json +19 -6
  5. package/skills.md +147 -0
  6. package/src/agents/index.ts +248 -0
  7. package/src/brain/loader.ts +136 -0
  8. package/src/cli.ts +411 -0
  9. package/src/config/index.ts +363 -0
  10. package/src/core/executor.ts +222 -0
  11. package/src/core/plugins.ts +148 -0
  12. package/src/core/types.ts +217 -0
  13. package/src/electron/main.ts +192 -0
  14. package/src/electron/preload.ts +25 -0
  15. package/src/electron/types.d.ts +20 -0
  16. package/src/index.ts +12 -0
  17. package/src/providers/antigravity-loader.ts +233 -0
  18. package/src/providers/antigravity.ts +585 -0
  19. package/src/providers/index.ts +523 -0
  20. package/src/sessions/index.ts +194 -0
  21. package/src/tools/index.ts +436 -0
  22. package/src/tui/index.tsx +784 -0
  23. package/src/utils/auth-prompt.ts +394 -0
  24. package/src/utils/index.ts +180 -0
  25. package/src/utils/native-picker.ts +71 -0
  26. package/src/utils/skills.ts +99 -0
  27. package/src/utils/table-integration-examples.ts +617 -0
  28. package/src/utils/table-utils.ts +401 -0
  29. package/src/web/build-ui.ts +27 -0
  30. package/src/web/server.ts +674 -0
  31. package/src/web/ui/dist/.gitkeep +0 -0
  32. package/src/web/ui/dist/main.css +1 -0
  33. package/src/web/ui/dist/main.js +320 -0
  34. package/src/web/ui/dist/main.js.map +20 -0
  35. package/src/web/ui/index.html +46 -0
  36. package/src/web/ui/src/App.tsx +143 -0
  37. package/src/web/ui/src/Modules/Safety/GuardianModal.tsx +83 -0
  38. package/src/web/ui/src/components/Layout/ContextPanel.tsx +243 -0
  39. package/src/web/ui/src/components/Layout/Header.tsx +91 -0
  40. package/src/web/ui/src/components/Layout/ModelSelector.tsx +235 -0
  41. package/src/web/ui/src/components/Layout/SessionStats.tsx +369 -0
  42. package/src/web/ui/src/components/Layout/Sidebar.tsx +895 -0
  43. package/src/web/ui/src/components/Modules/Chat/ChatStage.tsx +620 -0
  44. package/src/web/ui/src/components/Modules/Chat/MessageItem.tsx +446 -0
  45. package/src/web/ui/src/components/Modules/Editor/CommandInspector.tsx +71 -0
  46. package/src/web/ui/src/components/Modules/Editor/DiffViewer.tsx +83 -0
  47. package/src/web/ui/src/components/Modules/Terminal/TabbedTerminal.tsx +202 -0
  48. package/src/web/ui/src/components/Settings/SettingsModal.tsx +935 -0
  49. package/src/web/ui/src/config/models.ts +70 -0
  50. package/src/web/ui/src/main.tsx +13 -0
  51. package/src/web/ui/src/store/agentStore.ts +41 -0
  52. package/src/web/ui/src/store/uiStore.ts +64 -0
  53. package/src/web/ui/src/types/index.ts +54 -0
@@ -0,0 +1,235 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { ChevronDown, Sparkles, Loader2, AlertTriangle } from 'lucide-react';
4
+
5
+ interface Model {
6
+ id: string;
7
+ name: string;
8
+ provider: string;
9
+ isAvailable?: boolean;
10
+ }
11
+
12
+ interface ModelSelectorProps {
13
+ selectedModelId: string;
14
+ onModelChange: (modelId: string) => void;
15
+ }
16
+
17
+ export function ModelSelector({ selectedModelId, onModelChange }: ModelSelectorProps) {
18
+ const [models, setModels] = useState<Model[]>([]);
19
+ const [isOpen, setIsOpen] = useState(false);
20
+ const [isLoading, setIsLoading] = useState(true);
21
+ const [error, setError] = useState<string | null>(null);
22
+ const buttonRef = useRef<HTMLButtonElement>(null);
23
+ const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
24
+
25
+ // Fetch models from API
26
+ useEffect(() => {
27
+ const fetchModels = async () => {
28
+ try {
29
+ setIsLoading(true);
30
+ setError(null);
31
+
32
+ const response = await fetch('/api/models');
33
+ if (!response.ok) throw new Error('Failed to fetch models');
34
+
35
+ const data = await response.json();
36
+
37
+ if (Array.isArray(data) && data.length > 0) {
38
+ setModels(data);
39
+
40
+ // Auto-select first available model if none selected
41
+ if (!selectedModelId) {
42
+ const firstAvailable = data.find((m: Model) => m.isAvailable) || data[0];
43
+ if (firstAvailable) {
44
+ onModelChange(firstAvailable.id);
45
+ }
46
+ }
47
+ } else {
48
+ throw new Error('No models available');
49
+ }
50
+ } catch (err) {
51
+ console.error('Model fetch error:', err);
52
+ setError(err instanceof Error ? err.message : 'Failed to load models');
53
+
54
+ // Fallback models
55
+ const fallback: Model[] = [
56
+ { id: 'google/antigravity-claude-sonnet-4-5', name: 'Claude Sonnet 4.5', provider: 'google', isAvailable: true },
57
+ { id: 'google/antigravity-gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google', isAvailable: true },
58
+ ];
59
+ setModels(fallback);
60
+
61
+ if (!selectedModelId && fallback[0]) {
62
+ onModelChange(fallback[0].id);
63
+ }
64
+ } finally {
65
+ setIsLoading(false);
66
+ }
67
+ };
68
+
69
+ fetchModels();
70
+ }, []);
71
+
72
+ // Click outside to close
73
+ useEffect(() => {
74
+ if (!isOpen) return;
75
+
76
+ const handleClickOutside = (e: MouseEvent) => {
77
+ const target = e.target as HTMLElement;
78
+ // Check if click is outside both button and dropdown
79
+ if (
80
+ buttonRef.current && !buttonRef.current.contains(target) &&
81
+ !target.closest('.model-selector-dropdown')
82
+ ) {
83
+ setIsOpen(false);
84
+ }
85
+ };
86
+
87
+ const handleEscape = (e: KeyboardEvent) => {
88
+ if (e.key === 'Escape') setIsOpen(false);
89
+ };
90
+
91
+ document.addEventListener('mousedown', handleClickOutside);
92
+ document.addEventListener('keydown', handleEscape);
93
+
94
+ return () => {
95
+ document.removeEventListener('mousedown', handleClickOutside);
96
+ document.removeEventListener('keydown', handleEscape);
97
+ };
98
+ }, [isOpen]);
99
+
100
+ const selectedModel = models.find(m => m.id === selectedModelId);
101
+
102
+ const handleSelect = (modelId: string) => {
103
+ onModelChange(modelId);
104
+ setIsOpen(false);
105
+ };
106
+
107
+ const handleToggle = () => {
108
+ if (!isLoading && buttonRef.current) {
109
+ const rect = buttonRef.current.getBoundingClientRect();
110
+ setDropdownPosition({
111
+ top: rect.bottom + 8,
112
+ left: rect.left
113
+ });
114
+ setIsOpen(!isOpen);
115
+ console.log('Setting isOpen to:', !isOpen);
116
+ }
117
+ };
118
+
119
+ console.log('ModelSelector render - isOpen:', isOpen, 'isLoading:', isLoading, 'models:', models.length);
120
+
121
+ const dropdownContent = isOpen && !isLoading && (
122
+ <>
123
+ <style>{`
124
+ .model-dropdown-scroll::-webkit-scrollbar {
125
+ display: none;
126
+ }
127
+ `}</style>
128
+ <div
129
+ className="model-selector-dropdown fixed w-80 bg-zinc-900 border-2 border-cyan-500 rounded-lg shadow-2xl z-[9999] overflow-hidden"
130
+ style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }}
131
+ >
132
+ {error && (
133
+ <div className="flex items-center gap-2 px-3 py-2 bg-amber-500/10 border-b border-amber-500/20">
134
+ <AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />
135
+ <span className="text-xs text-amber-400">{error}</span>
136
+ </div>
137
+ )}
138
+
139
+ <div
140
+ className="max-h-[400px] overflow-y-auto model-dropdown-scroll"
141
+ style={{
142
+ scrollbarWidth: 'none', /* Firefox */
143
+ msOverflowStyle: 'none', /* IE and Edge */
144
+ }}
145
+ >
146
+ {models.length === 0 ? (
147
+ <div className="px-4 py-8 text-center text-zinc-500 text-sm">
148
+ No models available
149
+ </div>
150
+ ) : (
151
+ <div className="p-2 space-y-1">
152
+ {models.map((model) => {
153
+ const isSelected = model.id === selectedModelId;
154
+ const isDisabled = model.isAvailable === false;
155
+
156
+ return (
157
+ <button
158
+ key={model.id}
159
+ onClick={() => !isDisabled && handleSelect(model.id)}
160
+ disabled={isDisabled}
161
+ className={`
162
+ w-full px-3 py-2.5 rounded-md text-left transition-all
163
+ ${isSelected
164
+ ? 'bg-cyan-500/20 border border-cyan-500/30'
165
+ : 'hover:bg-zinc-800/50 border border-transparent'
166
+ }
167
+ ${isDisabled
168
+ ? 'opacity-40 cursor-not-allowed'
169
+ : 'cursor-pointer'
170
+ }
171
+ `}
172
+ >
173
+ <div className="flex items-start justify-between gap-3">
174
+ <div className="flex-1 min-w-0">
175
+ <div className="flex items-center gap-2">
176
+ <h4 className={`text-sm font-semibold truncate ${isSelected ? 'text-cyan-300' : 'text-zinc-200'}`}>
177
+ {model.name}
178
+ </h4>
179
+ {isSelected && (
180
+ <div className="w-2 h-2 rounded-full bg-cyan-500 shrink-0" />
181
+ )}
182
+ </div>
183
+ <div className="flex items-center gap-2 mt-0.5">
184
+ <p className="text-xs text-zinc-500 uppercase tracking-wide">
185
+ {model.provider}
186
+ </p>
187
+ {isDisabled && (
188
+ <span className="text-[10px] px-1.5 py-0.5 bg-zinc-800 text-zinc-500 rounded uppercase tracking-wide">
189
+ Key Missing
190
+ </span>
191
+ )}
192
+ </div>
193
+ </div>
194
+ </div>
195
+ </button>
196
+ );
197
+ })}
198
+ </div>
199
+ )}
200
+ </div>
201
+ </div>
202
+ </>
203
+ );
204
+
205
+ return (
206
+ <div className="relative">
207
+ {/* Trigger Button */}
208
+ <button
209
+ ref={buttonRef}
210
+ onClick={handleToggle}
211
+ disabled={isLoading}
212
+ className="flex items-center gap-2 px-3 py-1.5 bg-zinc-900/50 hover:bg-zinc-900 border border-zinc-800 hover:border-zinc-700 rounded-lg transition-all group disabled:opacity-50 disabled:cursor-not-allowed"
213
+ >
214
+ <div className="flex items-center justify-center w-5 h-5 rounded bg-gradient-to-br from-cyan-500/20 to-blue-500/20 group-hover:from-cyan-500/30 group-hover:to-blue-500/30 transition-all">
215
+ {isLoading ? (
216
+ <Loader2 className="w-3 h-3 text-cyan-400 animate-spin" />
217
+ ) : (
218
+ <Sparkles className="w-3 h-3 text-cyan-400" />
219
+ )}
220
+ </div>
221
+
222
+ <span className="text-sm font-semibold text-zinc-300 max-w-[120px] md:max-w-[200px] truncate">
223
+ {isLoading ? 'Loading...' : selectedModel?.name || 'Select Model'}
224
+ </span>
225
+
226
+ <ChevronDown
227
+ className={`w-4 h-4 text-zinc-500 transition-transform ${isOpen ? 'rotate-180' : ''}`}
228
+ />
229
+ </button>
230
+
231
+ {/* Dropdown Menu - Rendered via Portal */}
232
+ {typeof document !== 'undefined' && createPortal(dropdownContent, document.body)}
233
+ </div>
234
+ );
235
+ }
@@ -0,0 +1,369 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import type { Session } from '../../types';
3
+ import { Copy, CheckCircle2 } from 'lucide-react';
4
+ import {
5
+ MODEL_CONTEXT_LIMITS,
6
+ MODEL_PRICING,
7
+ MODEL_DISPLAY_NAMES,
8
+ PROVIDER_NAMES
9
+ } from '../../config/models';
10
+
11
+ interface SessionStatsData {
12
+ totalTokens: number;
13
+ inputTokens: number;
14
+ outputTokens: number;
15
+ reasoningTokens: number;
16
+ cacheTokensRead: number;
17
+ cacheTokensWrite: number;
18
+ userMessages: number;
19
+ assistantMessages: number;
20
+ modelId: string;
21
+ modelName: string;
22
+ providerId: string;
23
+ contextLimit: number | null;
24
+ totalCost: number;
25
+ usagePercent: number | null;
26
+ contextBreakdown: {
27
+ user: number;
28
+ assistant: number;
29
+ toolCalls: number;
30
+ other: number;
31
+ };
32
+ }
33
+
34
+ export function SessionStats({ sessionId }: { sessionId: string | null }) {
35
+ const [session, setSession] = useState<Session | null>(null);
36
+ const [stats, setStats] = useState<SessionStatsData | null>(null);
37
+ const [loading, setLoading] = useState(true);
38
+ const [copiedId, setCopiedId] = useState<string | null>(null);
39
+
40
+ // Fetch session data
41
+ const fetchSession = async () => {
42
+ if (!sessionId) {
43
+ setSession(null);
44
+ setStats(null);
45
+ setLoading(false);
46
+ return;
47
+ }
48
+
49
+ try {
50
+ const res = await fetch(`/api/sessions/${sessionId}`);
51
+ if (!res.ok) throw new Error('Failed to fetch session');
52
+ const data = await res.json();
53
+ setSession(data);
54
+ setStats(calculateStats(data));
55
+ setLoading(false);
56
+ } catch (err) {
57
+ console.error('Failed to fetch session:', err);
58
+ setLoading(false);
59
+ }
60
+ };
61
+
62
+ // Initial fetch
63
+ useEffect(() => {
64
+ fetchSession();
65
+ }, [sessionId]);
66
+
67
+ // Real-time polling every 2 seconds
68
+ useEffect(() => {
69
+ if (!sessionId) return;
70
+ const interval = setInterval(fetchSession, 2000);
71
+ return () => clearInterval(interval);
72
+ }, [sessionId]);
73
+
74
+ // Copy message ID to clipboard
75
+ const copyMessageId = (id: string) => {
76
+ navigator.clipboard.writeText(id);
77
+ setCopiedId(id);
78
+ setTimeout(() => setCopiedId(null), 2000);
79
+ };
80
+
81
+ // Empty state
82
+ if (!sessionId) {
83
+ return (
84
+ <div className="flex items-center justify-center h-64 text-zinc-500">
85
+ <p className="text-sm">No session selected</p>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ // Loading state
91
+ if (loading) {
92
+ return (
93
+ <div className="flex items-center justify-center h-64">
94
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-cyan-500" />
95
+ </div>
96
+ );
97
+ }
98
+
99
+ // Error state
100
+ if (!session || !stats) {
101
+ return (
102
+ <div className="flex items-center justify-center h-64 text-red-500">
103
+ <p className="text-sm">Failed to load session stats</p>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ return (
109
+ <div className="space-y-6">
110
+ {/* Stats Grid - Two Column Layout */}
111
+ <div className="grid grid-cols-2 gap-3 text-xs">
112
+ <StatRow label="Session" value={truncateId(session.id)} mono />
113
+ <StatRow label="Messages" value={session.messages.length} />
114
+
115
+ <StatRow label="Provider" value={stats.providerId} />
116
+ <StatRow label="Model" value={stats.modelName} />
117
+
118
+ <StatRow label="Context Limit" value={formatNumber(stats.contextLimit)} />
119
+ <StatRow label="Total Tokens" value={formatNumber(stats.totalTokens)} highlight="cyan" />
120
+
121
+ <StatRow label="Usage" value={stats.usagePercent !== null ? `${stats.usagePercent.toFixed(1)}%` : 'N/A'} highlight="amber" />
122
+ <StatRow label="Input Tokens" value={formatNumber(stats.inputTokens)} />
123
+
124
+ <StatRow label="Output Tokens" value={formatNumber(stats.outputTokens)} />
125
+ <StatRow label="Reasoning Tokens" value={formatNumber(stats.reasoningTokens)} />
126
+
127
+ <StatRow label="Cache Tokens (r/w)" value={`${stats.cacheTokensRead} / ${stats.cacheTokensWrite}`} />
128
+ <StatRow label="User Messages" value={stats.userMessages} />
129
+
130
+ <StatRow label="Assistant Messages" value={stats.assistantMessages} />
131
+ <StatRow label="Total Cost" value={`$${stats.totalCost.toFixed(2)}`} highlight="emerald" />
132
+
133
+ <StatRow label="First Message" value={session.messages[0] ? formatDate(session.messages[0].timestamp) : 'N/A'} />
134
+ <StatRow label="Last Activity" value={formatDate(session.updatedAt)} />
135
+ </div>
136
+
137
+ {/* Context Breakdown Visualization */}
138
+ <div>
139
+ <p className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-3">
140
+ Context Breakdown
141
+ </p>
142
+ <div className="space-y-2">
143
+ <div className="h-1.5 bg-zinc-900 rounded-full overflow-hidden flex">
144
+ <div
145
+ className="bg-emerald-500"
146
+ style={{ width: `${stats.contextBreakdown.user}%` }}
147
+ />
148
+ <div
149
+ className="bg-purple-500"
150
+ style={{ width: `${stats.contextBreakdown.assistant}%` }}
151
+ />
152
+ <div
153
+ className="bg-amber-500"
154
+ style={{ width: `${stats.contextBreakdown.toolCalls}%` }}
155
+ />
156
+ <div
157
+ className="bg-zinc-700"
158
+ style={{ width: `${stats.contextBreakdown.other}%` }}
159
+ />
160
+ </div>
161
+ <div className="flex items-center gap-3 text-[9px] text-zinc-400 flex-wrap">
162
+ <span className="flex items-center gap-1">
163
+ <div className="w-2 h-2 rounded-full bg-emerald-500" />
164
+ User {stats.contextBreakdown.user.toFixed(1)}%
165
+ </span>
166
+ <span className="flex items-center gap-1">
167
+ <div className="w-2 h-2 rounded-full bg-purple-500" />
168
+ Assistant {stats.contextBreakdown.assistant.toFixed(1)}%
169
+ </span>
170
+ <span className="flex items-center gap-1">
171
+ <div className="w-2 h-2 rounded-full bg-amber-500" />
172
+ Tool Calls {stats.contextBreakdown.toolCalls.toFixed(1)}%
173
+ </span>
174
+ <span className="flex items-center gap-1">
175
+ <div className="w-2 h-2 rounded-full bg-zinc-700" />
176
+ Other {stats.contextBreakdown.other.toFixed(1)}%
177
+ </span>
178
+ </div>
179
+ </div>
180
+ </div>
181
+
182
+ {/* Raw Messages List */}
183
+ <div>
184
+ <p className="text-[10px] font-bold uppercase tracking-wider text-zinc-500 mb-3">
185
+ Raw messages
186
+ </p>
187
+ <div className="max-h-64 overflow-y-auto custom-scrollbar space-y-1">
188
+ {session.messages.length === 0 ? (
189
+ <div className="p-4 rounded-xl border border-dashed border-zinc-800 text-center">
190
+ <p className="text-[10px] text-zinc-500 italic">No messages yet</p>
191
+ </div>
192
+ ) : (
193
+ session.messages.map(msg => (
194
+ <button
195
+ key={msg.id}
196
+ onClick={() => copyMessageId(msg.id)}
197
+ className="w-full flex items-center gap-2 text-[9px] font-mono p-2 rounded bg-zinc-900/40 border border-zinc-800 hover:bg-zinc-800/60 hover:border-cyan-500/30 transition-all cursor-pointer group"
198
+ >
199
+ <span className={`font-bold ${
200
+ msg.role === 'user' ? 'text-emerald-500' :
201
+ msg.role === 'assistant' ? 'text-purple-500' :
202
+ 'text-zinc-500'
203
+ }`}>
204
+ {msg.role}
205
+ </span>
206
+ <span className="text-zinc-600">•</span>
207
+ <span className="text-zinc-400 truncate flex-1 text-left">
208
+ {msg.id}
209
+ </span>
210
+ <span className="text-zinc-600 text-[8px] shrink-0">
211
+ {formatTime(msg.timestamp)}
212
+ </span>
213
+ {copiedId === msg.id ? (
214
+ <CheckCircle2 size={12} className="text-emerald-500 shrink-0" />
215
+ ) : (
216
+ <Copy size={12} className="text-zinc-600 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
217
+ )}
218
+ </button>
219
+ ))
220
+ )}
221
+ </div>
222
+ </div>
223
+ </div>
224
+ );
225
+ }
226
+
227
+ // Helper Components
228
+ function StatRow({
229
+ label,
230
+ value,
231
+ mono = false,
232
+ highlight
233
+ }: {
234
+ label: string;
235
+ value: string | number;
236
+ mono?: boolean;
237
+ highlight?: 'cyan' | 'amber' | 'emerald';
238
+ }) {
239
+ const highlightColors = {
240
+ cyan: 'text-cyan-500',
241
+ amber: 'text-amber-500',
242
+ emerald: 'text-emerald-500',
243
+ };
244
+
245
+ return (
246
+ <div className="p-2 rounded-lg bg-zinc-900/40 border border-zinc-800/60">
247
+ <p className="text-[9px] text-zinc-500 font-black uppercase tracking-widest mb-1">
248
+ {label}
249
+ </p>
250
+ <p className={`text-sm font-bold ${mono ? 'font-mono' : ''} ${
251
+ highlight ? highlightColors[highlight] : 'text-zinc-200'
252
+ }`}>
253
+ {value}
254
+ </p>
255
+ </div>
256
+ );
257
+ }
258
+
259
+ // Calculation Logic
260
+ function calculateStats(session: Session): SessionStatsData {
261
+ let totalTokens = 0;
262
+ let inputTokens = 0;
263
+ let outputTokens = 0;
264
+ let reasoningTokens = 0;
265
+ let cacheTokensRead = 0;
266
+ let cacheTokensWrite = 0;
267
+ let userMessages = 0;
268
+ let assistantMessages = 0;
269
+
270
+ // Aggregate from all messages
271
+ session.messages.forEach(msg => {
272
+ const usage = (msg as any).metadata?.usage;
273
+ if (usage) {
274
+ totalTokens += usage.totalTokens || 0;
275
+ inputTokens += usage.promptTokens || 0;
276
+ outputTokens += usage.completionTokens || 0;
277
+ reasoningTokens += usage.reasoningTokens || 0;
278
+ cacheTokensRead += usage.cacheReadTokens || usage.cachedTokens || 0;
279
+ cacheTokensWrite += usage.cacheWriteTokens || usage.cacheCreationTokens || 0;
280
+ }
281
+
282
+ if (msg.role === 'user') userMessages++;
283
+ if (msg.role === 'assistant') assistantMessages++;
284
+ });
285
+
286
+ // Get model info from session metadata
287
+ const metadata = (session as any).metadata || {};
288
+ const modelId = metadata.modelId || 'unknown';
289
+ const providerId = metadata.providerId || getProviderFromModelId(modelId);
290
+ const contextLimit = MODEL_CONTEXT_LIMITS[modelId] || null;
291
+
292
+ // Calculate cost
293
+ const pricing = MODEL_PRICING[modelId];
294
+ const totalCost = pricing
295
+ ? (inputTokens / 1_000_000) * pricing.input + (outputTokens / 1_000_000) * pricing.output
296
+ : 0;
297
+
298
+ // Calculate usage percentage
299
+ const usagePercent = contextLimit ? (totalTokens / contextLimit) * 100 : null;
300
+
301
+ // Context breakdown (simplified estimation based on message counts)
302
+ // In reality, this would require counting tokens per message role
303
+ const estimatedUserTokens = userMessages > 0 ? Math.floor(totalTokens * 0.1) : 0;
304
+ const estimatedToolTokens = totalTokens > 0 ? Math.floor(totalTokens * 0.7) : 0;
305
+ const estimatedAssistantTokens = totalTokens - estimatedUserTokens - estimatedToolTokens;
306
+ const estimatedOtherTokens = 0;
307
+
308
+ return {
309
+ totalTokens,
310
+ inputTokens,
311
+ outputTokens,
312
+ reasoningTokens,
313
+ cacheTokensRead,
314
+ cacheTokensWrite,
315
+ userMessages,
316
+ assistantMessages,
317
+ modelId,
318
+ modelName: MODEL_DISPLAY_NAMES[modelId] || modelId,
319
+ providerId: PROVIDER_NAMES[providerId] || providerId,
320
+ contextLimit,
321
+ totalCost,
322
+ usagePercent,
323
+ contextBreakdown: {
324
+ user: totalTokens > 0 ? (estimatedUserTokens / totalTokens) * 100 : 0,
325
+ assistant: totalTokens > 0 ? (estimatedAssistantTokens / totalTokens) * 100 : 0,
326
+ toolCalls: totalTokens > 0 ? (estimatedToolTokens / totalTokens) * 100 : 0,
327
+ other: totalTokens > 0 ? (estimatedOtherTokens / totalTokens) * 100 : 0,
328
+ },
329
+ };
330
+ }
331
+
332
+ // Utility Functions
333
+ function truncateId(id: string): string {
334
+ return id.length > 24 ? `${id.slice(0, 24)}...` : id;
335
+ }
336
+
337
+ function formatNumber(num: number | null): string {
338
+ if (num === null || num === undefined) return 'N/A';
339
+ return num.toLocaleString();
340
+ }
341
+
342
+ function formatDate(date: string | Date | undefined): string {
343
+ if (!date) return 'N/A';
344
+ const d = new Date(date);
345
+ return d.toLocaleString('en-US', {
346
+ month: 'short',
347
+ day: 'numeric',
348
+ year: 'numeric',
349
+ hour: 'numeric',
350
+ minute: '2-digit',
351
+ hour12: true
352
+ });
353
+ }
354
+
355
+ function formatTime(timestamp: string | Date): string {
356
+ const d = new Date(timestamp);
357
+ return d.toLocaleTimeString('en-US', {
358
+ hour: 'numeric',
359
+ minute: '2-digit',
360
+ hour12: true
361
+ });
362
+ }
363
+
364
+ function getProviderFromModelId(modelId: string): string {
365
+ if (modelId.startsWith('gemini')) return 'google';
366
+ if (modelId.startsWith('claude')) return 'anthropic';
367
+ if (modelId.startsWith('gpt') || modelId.startsWith('o1') || modelId.startsWith('o3')) return 'openai';
368
+ return 'unknown';
369
+ }