From 385271cb759b2b0078143a731114483927600588 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Wed, 17 Dec 2025 10:54:04 +0000 Subject: [PATCH] Remove Token Services. The token services weren't providing much value and have become more maintenance than they are worth given the addition of binary conversation data. --- AIClasses/Claude/ClaudeTokenService.ts | 41 --- AIClasses/Gemini/GeminiTokenService.ts | 36 -- AIClasses/ITokenService.ts | 3 - AIClasses/OpenAI/OpenAITokenService.ts | 14 - Components/ChatWindow.svelte | 4 - Services/ChatService.ts | 38 --- Services/ServiceRegistration.ts | 9 - Services/Services.ts | 2 - Services/StatusBarService.ts | 87 ----- Views/MainView.ts | 8 - __tests__/Services/ChatService.test.ts | 93 +---- __tests__/Services/StatusBarService.test.ts | 356 -------------------- package.json | 1 - 13 files changed, 1 insertion(+), 691 deletions(-) delete mode 100644 AIClasses/Claude/ClaudeTokenService.ts delete mode 100644 AIClasses/Gemini/GeminiTokenService.ts delete mode 100644 AIClasses/ITokenService.ts delete mode 100644 AIClasses/OpenAI/OpenAITokenService.ts delete mode 100644 Services/StatusBarService.ts delete mode 100644 __tests__/Services/StatusBarService.test.ts diff --git a/AIClasses/Claude/ClaudeTokenService.ts b/AIClasses/Claude/ClaudeTokenService.ts deleted file mode 100644 index b8df627..0000000 --- a/AIClasses/Claude/ClaudeTokenService.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ITokenService } from "AIClasses/ITokenService"; -import Anthropic from '@anthropic-ai/sdk' -import { Resolve } from "Services/DependencyService"; -import { Services } from "Services/Services"; -import { Role } from "Enums/Role"; -import { AIProvider } from "Enums/ApiProvider"; -import type { SettingsService } from "Services/SettingsService"; -import type { AbortService } from "Services/AbortService"; - -export class ClaudeTokenService implements ITokenService { - - private ai: Anthropic; - private model: string; - private readonly abortService: AbortService; - - public constructor() { - const settingsService = Resolve(Services.SettingsService); - this.ai = new Anthropic({ - apiKey: settingsService.getApiKeyForProvider(AIProvider.Claude), - dangerouslyAllowBrowser: true - }); - this.model = settingsService.settings.model; - this.abortService = Resolve(Services.AbortService); - } - - public async countTokens(input: string): Promise { - if (input.trim() === "") { - return 0; - } - - // to maintain the convenience of the interface we just submit the entire input as one message - const result = await this.ai.messages.countTokens({ - model: this.model, - messages: [ - { role: Role.User, content: input } - ] - }) - return result.input_tokens; - } - -} \ No newline at end of file diff --git a/AIClasses/Gemini/GeminiTokenService.ts b/AIClasses/Gemini/GeminiTokenService.ts deleted file mode 100644 index 2806c81..0000000 --- a/AIClasses/Gemini/GeminiTokenService.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ITokenService } from "AIClasses/ITokenService"; -import { CountTokensResponse, GoogleGenAI } from '@google/genai' -import { Resolve } from "Services/DependencyService"; -import { Services } from "Services/Services"; -import { AIProvider } from "Enums/ApiProvider"; -import type { SettingsService } from "Services/SettingsService"; -import type { AbortService } from "Services/AbortService"; - -export class GeminiTokenService implements ITokenService { - - private readonly ai: GoogleGenAI; - private model: string; - private readonly abortService: AbortService; - - public constructor() { - const settingsService = Resolve(Services.SettingsService); - this.ai = new GoogleGenAI({ - apiKey: settingsService.getApiKeyForProvider(AIProvider.Gemini) - }); - this.model = settingsService.settings.model; - this.abortService = Resolve(Services.AbortService); - } - - public async countTokens(input: string): Promise { - if (input.trim() === "") { - return 0; - } - - const result: CountTokensResponse = await this.ai.models.countTokens({ - model: this.model, - contents: input - }); - return result.totalTokens ?? -1; - } - -} \ No newline at end of file diff --git a/AIClasses/ITokenService.ts b/AIClasses/ITokenService.ts deleted file mode 100644 index bb291c8..0000000 --- a/AIClasses/ITokenService.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface ITokenService { - countTokens(input: string): Promise; -} \ No newline at end of file diff --git a/AIClasses/OpenAI/OpenAITokenService.ts b/AIClasses/OpenAI/OpenAITokenService.ts deleted file mode 100644 index 47bcf38..0000000 --- a/AIClasses/OpenAI/OpenAITokenService.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ITokenService } from "AIClasses/ITokenService"; -import { countTokens } from 'gpt-tokenizer' - -export class OpenAITokenService implements ITokenService { - - public countTokens(input: string): Promise { - if (input.trim() === "") { - return Promise.resolve(0); - } - - return Promise.resolve(countTokens(input)); - } - -} \ No newline at end of file diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index 1d58739..79fb923 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -49,7 +49,6 @@ if (chatContainer) { plugin.registerDomEvent(chatContainer, 'click', handleLinkClick); } - chatService.setStatusBarTokens(0, 0); }); async function handleLinkClick(evt: MouseEvent) { @@ -121,7 +120,6 @@ isSubmitting = false; abortService.reset(); chatArea.updateChatAreaLayout("smooth", true); - await chatService.updateTokenDisplay(conversation); }, onCancel: () => { cancelling = true; @@ -131,7 +129,6 @@ $: if ($conversationStore.shouldReset) { conversation = new Conversation(); - chatService.setStatusBarTokens(0, 0); isSubmitting = false; currentStreamingMessageId = null; @@ -155,7 +152,6 @@ conversation = loadedConversation; conversationService.setCurrentConversationPath(filePath); chatService.onNameChanged?.(loadedConversation.title); - chatService.updateTokenDisplay(loadedConversation); conversationStore.clearLoadFlag(); chatArea.updateChatAreaLayout("instant", true); } diff --git a/Services/ChatService.ts b/Services/ChatService.ts index df6aca7..b43a3d2 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -5,9 +5,6 @@ import type { IAIClass } from "AIClasses/IAIClass"; import type { ConversationFileSystemService } from "./ConversationFileSystemService"; import type { AIFunctionService } from "./AIFunctionService"; import type { ConversationNamingService } from "./ConversationNamingService"; -import type { ITokenService } from "AIClasses/ITokenService"; -import type { IPrompt } from "AIClasses/IPrompt"; -import type { StatusBarService } from "./StatusBarService"; import { Conversation } from "Conversations/Conversation"; import { ConversationContent } from "Conversations/ConversationContent"; import { Role } from "Enums/Role"; @@ -32,9 +29,6 @@ export class ChatService { private conversationService: ConversationFileSystemService; private aiFunctionService: AIFunctionService; private namingService: ConversationNamingService; - private tokenService: ITokenService | undefined; - private prompt: IPrompt; - private statusBarService: StatusBarService; private eventService: EventService; private abortService: AbortService; @@ -45,8 +39,6 @@ export class ChatService { this.conversationService = Resolve(Services.ConversationFileSystemService); this.aiFunctionService = Resolve(Services.AIFunctionService); this.namingService = Resolve(Services.ConversationNamingService); - this.prompt = Resolve(Services.IPrompt); - this.statusBarService = Resolve(Services.StatusBarService); this.eventService = Resolve(Services.EventService); this.abortService = Resolve(Services.AbortService); this.semaphore = new Semaphore(1, false); @@ -56,7 +48,6 @@ export class ChatService { public resolveAIProvider() { this.ai = Resolve(Services.IAIClass); - this.tokenService = Resolve(Services.ITokenService); } public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, formattedRequest: string, callbacks: IChatServiceCallbacks) { @@ -131,35 +122,6 @@ export class ChatService { this.eventService.trigger(Event.DiffClosed); } - public async updateTokenDisplay(conversation: Conversation) { - if (this.tokenService === undefined) { - return; - } - - const systemInstruction = this.prompt.systemInstruction(); - const userInstruction = await this.prompt.userInstruction(); - - const inputMessages = conversation.contents - .filter(message => message.role === Role.User && !message.isFunctionCallResponse) - .map(message => message.promptContent) - .join("\n"); - - const outputMessages = conversation.contents - .filter(message => message.role === Role.Assistant && !message.isFunctionCall) - .map(message => message.content) - .join("\n"); - - const inputText = systemInstruction + "\n" + userInstruction + "\n" + inputMessages; - const inputTokens = await this.tokenService.countTokens(inputText); - const outputTokens = await this.tokenService.countTokens(outputMessages); - - this.setStatusBarTokens(inputTokens, outputTokens); - } - - public setStatusBarTokens(inputTokens: number, outputTokens: number) { - this.statusBarService.animateTokens(inputTokens, outputTokens); - } - private async saveConversation(conversation: Conversation) { const result = await this.conversationService.saveConversation(conversation); if (result instanceof Error) { diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index 74ce8cf..af48bf5 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -18,11 +18,6 @@ import { WorkSpaceService } from "./WorkSpaceService"; import { ChatService } from "./ChatService"; import { ConversationNamingService } from "./ConversationNamingService"; import { VaultService } from "./VaultService"; -import type { ITokenService } from "AIClasses/ITokenService"; -import { GeminiTokenService } from "AIClasses/Gemini/GeminiTokenService"; -import { StatusBarService } from "./StatusBarService"; -import { ClaudeTokenService } from "AIClasses/Claude/ClaudeTokenService"; -import { OpenAITokenService } from "AIClasses/OpenAI/OpenAITokenService"; import { ClaudeConversationNamingService } from "AIClasses/Claude/ClaudeConversationNamingService"; import { Claude } from "AIClasses/Claude/Claude"; import { OpenAIConversationNamingService } from "AIClasses/OpenAI/OpenAIConversationNamingService"; @@ -47,7 +42,6 @@ export async function RegisterPlugin(plugin: VaultkeeperAIPlugin) { export function RegisterDependencies() { RegisterSingleton(Services.EventService, new EventService()); RegisterSingleton(Services.AbortService, new AbortService()); - RegisterSingleton(Services.StatusBarService, new StatusBarService()); RegisterSingleton(Services.HTMLService, new HTMLService()); RegisterSingleton(Services.SanitiserService, new SanitiserService()); RegisterSingleton(Services.DiffService, new DiffService()); @@ -79,17 +73,14 @@ export function RegisterAiProvider() { if (provider == AIProvider.Claude) { RegisterSingleton(Services.IAIClass, new Claude()); - RegisterSingleton(Services.ITokenService, new ClaudeTokenService()); RegisterSingleton(Services.IConversationNamingService, new ClaudeConversationNamingService()); } else if (provider == AIProvider.Gemini) { RegisterSingleton(Services.IAIClass, new Gemini()); - RegisterSingleton(Services.ITokenService, new GeminiTokenService()); RegisterSingleton(Services.IConversationNamingService, new GeminiConversationNamingService()); } else if (provider == AIProvider.OpenAI) { RegisterSingleton(Services.IAIClass, new OpenAI()); - RegisterSingleton(Services.ITokenService, new OpenAITokenService()); RegisterSingleton(Services.IConversationNamingService, new OpenAIConversationNamingService()); } diff --git a/Services/Services.ts b/Services/Services.ts index ebd9af4..69c8556 100644 --- a/Services/Services.ts +++ b/Services/Services.ts @@ -3,7 +3,6 @@ export class Services { static SettingsService = Symbol("SettingsService"); static EventService = Symbol("EventService"); static AbortService = Symbol("AbortService"); - static StatusBarService = Symbol("StatusBarService"); static HTMLService = Symbol("HTMLService"); static VaultService = Symbol("VaultService"); static VaultCacheService = Symbol("VaultCacheService"); @@ -28,7 +27,6 @@ export class Services { // interfaces static IAIClass = Symbol("IAIClass"); static IPrompt = Symbol("IPrompt"); - static ITokenService = Symbol("ITokenService"); static IConversationNamingService = Symbol("IConversationNamingService"); // modals diff --git a/Services/StatusBarService.ts b/Services/StatusBarService.ts deleted file mode 100644 index f8b8ee6..0000000 --- a/Services/StatusBarService.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type VaultkeeperAIPlugin from "main"; -import { Resolve } from "./DependencyService"; -import { Services } from "./Services"; -import { Component } from "obsidian"; - -export class StatusBarService extends Component { - - private readonly plugin: VaultkeeperAIPlugin; - private statusBarItem: HTMLElement | null; - private currentInputTokens: number = 0; - private currentOutputTokens: number = 0; - private animationFrame: number | null = null; - - public constructor() { - super(); - this.plugin = Resolve(Services.VaultkeeperAIPlugin); - } - - override onunload(): void { - this.removeStatusBarMessage(); - } - - public setStatusBarMessage(message: string) { - if (this.statusBarItem == null) { - this.createStatusBarMessage(); - } - - this.statusBarItem?.empty(); - this.statusBarItem?.createEl("span", { text: message }); - } - - public animateTokens(targetInputTokens: number, targetOutputTokens: number) { - // Cancel any ongoing animation - if (this.animationFrame !== null) { - cancelAnimationFrame(this.animationFrame); - } - - const startInputTokens = this.currentInputTokens; - const startOutputTokens = this.currentOutputTokens; - const startTime = performance.now(); - const duration = 1000; - - const animate = (currentTime: number) => { - const elapsed = currentTime - startTime; - const progress = Math.min(elapsed / duration, 1); - - // Ease-out cubic for smooth deceleration - const eased = 1 - Math.pow(1 - progress, 3); - - // Calculate current values - this.currentInputTokens = Math.round( - startInputTokens + (targetInputTokens - startInputTokens) * eased - ); - this.currentOutputTokens = Math.round( - startOutputTokens + (targetOutputTokens - startOutputTokens) * eased - ); - - this.setStatusBarMessage( - `Input Tokens: ${this.currentInputTokens} / Output Tokens: ${this.currentOutputTokens}` - ); - - // Continue animation if not complete - if (progress < 1) { - this.animationFrame = requestAnimationFrame(animate); - } else { - this.animationFrame = null; - } - }; - - this.animationFrame = requestAnimationFrame(animate); - } - - public removeStatusBarMessage() { - if (this.animationFrame !== null) { - cancelAnimationFrame(this.animationFrame); - this.animationFrame = null; - } - this.statusBarItem?.remove(); - this.statusBarItem = null; - } - - private createStatusBarMessage() { - this.statusBarItem?.remove(); - this.statusBarItem = this.plugin.addStatusBarItem(); - this.statusBarItem.empty(); - } -} \ No newline at end of file diff --git a/Views/MainView.ts b/Views/MainView.ts index 924c3a7..4aff540 100644 --- a/Views/MainView.ts +++ b/Views/MainView.ts @@ -2,9 +2,6 @@ import { ItemView, WorkspaceLeaf } from 'obsidian'; import { mount, unmount } from 'svelte'; import ChatWindow from 'Components/ChatWindow.svelte'; import TopBar from 'Components/TopBar.svelte'; -import type { StatusBarService } from 'Services/StatusBarService'; -import { Resolve } from 'Services/DependencyService'; -import { Services } from 'Services/Services'; export const VIEW_TYPE_MAIN = 'vaultkeeper-ai-main-view'; @@ -15,8 +12,6 @@ interface ChatWindowComponent { export class MainView extends ItemView { - private statusBarService: StatusBarService = Resolve(Services.StatusBarService); - constructor(leaf: WorkspaceLeaf) { super(leaf); } @@ -40,7 +35,6 @@ export class MainView extends ItemView { const container = this.contentEl; container.empty(); - // Mount TopBar with reference to ChatWindow's focus function this.topBar = mount(TopBar, { target: container, props: { @@ -52,7 +46,6 @@ export class MainView extends ItemView { } }); - // Mount ChatWindow first this.input = mount(ChatWindow, { target: container, props: {} @@ -68,6 +61,5 @@ export class MainView extends ItemView { if (this.input) { await unmount(this.input); } - this.statusBarService.removeStatusBarMessage(); } } \ No newline at end of file diff --git a/__tests__/Services/ChatService.test.ts b/__tests__/Services/ChatService.test.ts index 227f3fe..9256d9a 100644 --- a/__tests__/Services/ChatService.test.ts +++ b/__tests__/Services/ChatService.test.ts @@ -26,7 +26,6 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { let mockNamingService: any; let mockPrompt: any; let mockStatusBarService: any; - let mockTokenService: any; let mockEventService: any; let abortService: AbortService; @@ -53,10 +52,6 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { animateTokens: vi.fn() }; - mockTokenService = { - countTokens: vi.fn().mockResolvedValue(100) - }; - // Mock EventService since it extends Obsidian's Events class mockEventService = { trigger: vi.fn(), @@ -72,7 +67,6 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { RegisterSingleton(Services.AIFunctionService, mockAIFunctionService); RegisterSingleton(Services.ConversationNamingService, mockNamingService); RegisterSingleton(Services.IPrompt, mockPrompt); - RegisterSingleton(Services.StatusBarService, mockStatusBarService); RegisterSingleton(Services.EventService, mockEventService); RegisterSingleton(Services.AbortService, abortService); @@ -102,7 +96,6 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { it('should resolve AI provider services', () => { const mockAI = { streamRequest: vi.fn() }; RegisterSingleton(Services.IAIClass, mockAI as any); - RegisterSingleton(Services.ITokenService, mockTokenService); service.resolveAIProvider(); @@ -125,90 +118,6 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { }); }); - describe('setStatusBarTokens', () => { - beforeEach(() => { - // Need to resolve AI provider to initialize token service - RegisterSingleton(Services.IAIClass, { streamRequest: vi.fn() } as any); - RegisterSingleton(Services.ITokenService, mockTokenService); - service.resolveAIProvider(); - }); - - it('should call status bar service with token counts', () => { - service.setStatusBarTokens(100, 50); - - expect(mockStatusBarService.animateTokens).toHaveBeenCalledWith(100, 50); - }); - - it('should handle zero tokens', () => { - service.setStatusBarTokens(0, 0); - - expect(mockStatusBarService.animateTokens).toHaveBeenCalledWith(0, 0); - }); - - it('should handle large token counts', () => { - service.setStatusBarTokens(100000, 50000); - - expect(mockStatusBarService.animateTokens).toHaveBeenCalledWith(100000, 50000); - }); - }); - - describe('updateTokenDisplay', () => { - beforeEach(() => { - // Need to resolve AI provider to initialize token service - RegisterSingleton(Services.IAIClass, { streamRequest: vi.fn() } as any); - RegisterSingleton(Services.ITokenService, mockTokenService); - service.resolveAIProvider(); - }); - - it('should count tokens for conversation contents', async () => { - const conversation = new Conversation(); - conversation.contents.push(new ConversationContent(Role.User, 'User message')); - conversation.contents.push(new ConversationContent(Role.Assistant, 'Assistant response')); - - await service.updateTokenDisplay(conversation); - - expect(mockTokenService.countTokens).toHaveBeenCalled(); - expect(mockStatusBarService.animateTokens).toHaveBeenCalled(); - }); - - it('should include system and user instructions in token count', async () => { - const conversation = new Conversation(); - conversation.contents.push(new ConversationContent(Role.User, 'Test')); - - await service.updateTokenDisplay(conversation); - - expect(mockPrompt.systemInstruction).toHaveBeenCalled(); - expect(mockPrompt.userInstruction).toHaveBeenCalled(); - }); - - it('should filter out function call responses from user messages', async () => { - const conversation = new Conversation(); - conversation.contents.push(new ConversationContent(Role.User, 'Regular message')); - conversation.contents.push( - new ConversationContent(Role.User, 'Function result', '', '', new Date(), false, true, 'tool-id') - ); - - await service.updateTokenDisplay(conversation); - - expect(mockTokenService.countTokens).toHaveBeenCalled(); - }); - - it('should handle empty conversation', async () => { - const conversation = new Conversation(); - - await service.updateTokenDisplay(conversation); - - expect(mockStatusBarService.animateTokens).toHaveBeenCalled(); - }); - - it('should not throw if token service not initialized', async () => { - const newService = new ChatService(); - const conversation = new Conversation(); - - await expect(newService.updateTokenDisplay(conversation)).resolves.not.toThrow(); - }); - }); - describe('onNameChanged callback', () => { it('should allow setting callback function', () => { const callback = vi.fn(); @@ -327,7 +236,7 @@ describe('ChatService - Integration Tests (Sync Methods Only)', () => { conversation.contents.push(new ConversationContent(Role.User, 'Request')); conversation.contents.push(new ConversationContent(Role.Assistant, 'Making a function call', '', '{"name":"test"}', new Date(), true)); // Function response (already User role with isFunctionCallResponse) - conversation.contents.push(new ConversationContent(Role.User, 'Function result', 'Function result', '', new Date(), false, true, 'tool-1')); + conversation.contents.push(new ConversationContent(Role.User, 'Function result', 'Function result', '', new Date(), false, true, false, 'tool-1')); // Assistant processes the function response conversation.contents.push(new ConversationContent(Role.Assistant, 'Final response')); diff --git a/__tests__/Services/StatusBarService.test.ts b/__tests__/Services/StatusBarService.test.ts deleted file mode 100644 index 74b6c57..0000000 --- a/__tests__/Services/StatusBarService.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { StatusBarService } from '../../Services/StatusBarService'; -import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService'; -import { Services } from '../../Services/Services'; - -describe('StatusBarService', () => { - let service: StatusBarService; - let mockPlugin: any; - let mockStatusBarItem: any; - let rafCallbacks: Array<(time: number) => void>; - let currentTime: number; - - beforeEach(() => { - - // Track RAF callbacks - rafCallbacks = []; - currentTime = 0; - - // Mock requestAnimationFrame - global.requestAnimationFrame = vi.fn((callback: (time: number) => void) => { - rafCallbacks.push(callback); - return rafCallbacks.length; - }); - - // Mock cancelAnimationFrame - global.cancelAnimationFrame = vi.fn((handle: number) => { - if (handle > 0 && handle <= rafCallbacks.length) { - rafCallbacks[handle - 1] = () => {}; // Clear callback - } - }); - - // Mock performance.now - global.performance.now = vi.fn(() => currentTime); - - // Mock status bar item - mockStatusBarItem = { - empty: vi.fn(), - createEl: vi.fn(), - remove: vi.fn() - }; - - // Mock plugin - mockPlugin = { - addStatusBarItem: vi.fn().mockReturnValue(mockStatusBarItem) - }; - RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin); - - service = new StatusBarService(); - }); - - afterEach(() => { - // Clean up service resources (cancel animations, remove DOM elements) - service.removeStatusBarMessage(); - - // Clear singleton registry to prevent memory leaks - DeregisterAllServices(); - - // Clear RAF callback references - rafCallbacks = []; - - // Restore all mocks - vi.restoreAllMocks(); - }); - - describe('Constructor and Initialization', () => { - it('should initialize with dependencies', () => { - expect(service).toBeDefined(); - }); - - it('should not create status bar item until first use', () => { - expect(mockPlugin.addStatusBarItem).not.toHaveBeenCalled(); - }); - }); - - describe('setStatusBarMessage', () => { - it('should create status bar item on first call', () => { - service.setStatusBarMessage('Test message'); - - expect(mockPlugin.addStatusBarItem).toHaveBeenCalled(); - expect(mockStatusBarItem.empty).toHaveBeenCalled(); - expect(mockStatusBarItem.createEl).toHaveBeenCalledWith('span', { text: 'Test message' }); - }); - - it('should reuse existing status bar item on subsequent calls', () => { - service.setStatusBarMessage('First message'); - service.setStatusBarMessage('Second message'); - - // Should only create once - expect(mockPlugin.addStatusBarItem).toHaveBeenCalledTimes(1); - // empty() is called: createStatusBarMessage(1) + setStatusBarMessage first call(2) + second call(3) = 3 times - expect(mockStatusBarItem.empty).toHaveBeenCalledTimes(3); - expect(mockStatusBarItem.createEl).toHaveBeenCalledTimes(2); - }); - - it('should update message content', () => { - service.setStatusBarMessage('Message 1'); - service.setStatusBarMessage('Message 2'); - - expect(mockStatusBarItem.createEl).toHaveBeenLastCalledWith('span', { text: 'Message 2' }); - }); - }); - - describe('removeStatusBarMessage', () => { - it('should remove status bar item', () => { - service.setStatusBarMessage('Test'); - service.removeStatusBarMessage(); - - expect(mockStatusBarItem.remove).toHaveBeenCalled(); - }); - - it('should cancel ongoing animation', () => { - service.animateTokens(100, 200); - expect(rafCallbacks.length).toBeGreaterThan(0); - - service.removeStatusBarMessage(); - - expect(global.cancelAnimationFrame).toHaveBeenCalled(); - }); - - it('should handle being called when no status bar exists', () => { - // Should not throw - expect(() => service.removeStatusBarMessage()).not.toThrow(); - }); - - it('should clear status bar reference', () => { - service.setStatusBarMessage('Test'); - service.removeStatusBarMessage(); - - // Next call should recreate - service.setStatusBarMessage('New message'); - expect(mockPlugin.addStatusBarItem).toHaveBeenCalledTimes(2); - }); - }); - - describe('animateTokens', () => { - it('should start animation with requestAnimationFrame', () => { - service.animateTokens(100, 200); - - expect(global.requestAnimationFrame).toHaveBeenCalled(); - expect(rafCallbacks.length).toBe(1); - }); - - it('should animate from current values to target values', () => { - // Start from 0, animate to 100 input and 200 output - service.animateTokens(100, 200); - - // Run first frame at t=0 - currentTime = 0; - rafCallbacks[0](currentTime); - - // Should show start values - expect(mockStatusBarItem.createEl).toHaveBeenCalledWith('span', { - text: 'Input Tokens: 0 / Output Tokens: 0' - }); - - // Run frame at t=500 (halfway through 1000ms animation) - currentTime = 500; - rafCallbacks[1](currentTime); - - // Should be roughly halfway (with easing) - const lastCall = mockStatusBarItem.createEl.mock.calls[mockStatusBarItem.createEl.mock.calls.length - 1]; - const text = lastCall[1].text; - expect(text).toContain('Input Tokens:'); - expect(text).toContain('Output Tokens:'); - }); - - it('should complete animation after 1000ms', () => { - service.animateTokens(100, 200); - - // Simulate animation frames until completion - currentTime = 0; - let iterations = 0; - const maxIterations = 100; // Safety limit - - while (currentTime < 1000 && iterations < maxIterations) { - const callbackIndex = rafCallbacks.length - 1; // Get most recent callback - if (callbackIndex >= 0) { - rafCallbacks[callbackIndex](currentTime); - } - currentTime += 16; // ~60fps - iterations++; - } - - // Run final frame at exactly 1000ms to ensure completion - currentTime = 1000; - const finalCallbackIndex = rafCallbacks.length - 1; - if (finalCallbackIndex >= 0) { - rafCallbacks[finalCallbackIndex](currentTime); - } - - // Should reach target values - const lastCall = mockStatusBarItem.createEl.mock.calls[mockStatusBarItem.createEl.mock.calls.length - 1]; - expect(lastCall[1].text).toBe('Input Tokens: 100 / Output Tokens: 200'); - }); - - it('should cancel previous animation when starting new one', () => { - service.animateTokens(50, 100); - const firstAnimationFrame = rafCallbacks.length; - - service.animateTokens(150, 250); - - expect(global.cancelAnimationFrame).toHaveBeenCalled(); - }); - - it('should handle zero target values', () => { - // Start with some values - (service as any).currentInputTokens = 100; - (service as any).currentOutputTokens = 200; - - service.animateTokens(0, 0); - - // Run to completion - currentTime = 0; - rafCallbacks[0](currentTime); - currentTime = 1000; - if (rafCallbacks.length > 1) { - rafCallbacks[rafCallbacks.length - 1](currentTime); - } - - // Should animate down to zero - const lastCall = mockStatusBarItem.createEl.mock.calls[mockStatusBarItem.createEl.mock.calls.length - 1]; - expect(lastCall[1].text).toContain('0'); - }); - - it('should handle large token jumps', () => { - service.animateTokens(10000, 20000); - - currentTime = 0; - rafCallbacks[0](currentTime); - - // Should not throw and should format correctly - expect(mockStatusBarItem.createEl).toHaveBeenCalled(); - }); - - it('should round token values during animation', () => { - service.animateTokens(99, 199); - - currentTime = 0; - rafCallbacks[0](currentTime); - - // All displayed values should be integers - mockStatusBarItem.createEl.mock.calls.forEach((call: any) => { - const text = call[1].text; - const matches = text.match(/\d+/g); - if (matches) { - matches.forEach((num: string) => { - expect(num).toBe(Math.round(parseInt(num)).toString()); - }); - } - }); - }); - - it('should use ease-out cubic easing', () => { - service.animateTokens(100, 200); - - // Collect values at different times - const values: number[] = []; - - for (let t = 0; t <= 1000; t += 100) { - currentTime = t; - const currentIndex = rafCallbacks.length - 1; - if (currentIndex >= 0) { - rafCallbacks[currentIndex](currentTime); - } - - // Extract input token value - const lastCall = mockStatusBarItem.createEl.mock.calls[mockStatusBarItem.createEl.mock.calls.length - 1]; - const match = lastCall[1].text.match(/Input Tokens: (\d+)/); - if (match) { - values.push(parseInt(match[1])); - } - } - - // With ease-out, changes should be larger at start than at end - if (values.length >= 3) { - const earlyChange = values[1] - values[0]; - const lateChange = values[values.length - 1] - values[values.length - 2]; - expect(earlyChange).toBeGreaterThan(lateChange); - } - }); - }); - - describe('Multiple Animation Cycles', () => { - it('should handle rapid consecutive calls', () => { - service.animateTokens(50, 100); - service.animateTokens(100, 150); - service.animateTokens(150, 200); - - // Should cancel previous animations - expect(global.cancelAnimationFrame).toHaveBeenCalled(); - - // Run the latest animation to completion - currentTime = 1000; - const latestCallback = rafCallbacks[rafCallbacks.length - 1]; - if (latestCallback) { - latestCallback(currentTime); - } - - const lastCall = mockStatusBarItem.createEl.mock.calls[mockStatusBarItem.createEl.mock.calls.length - 1]; - expect(lastCall[1].text).toContain('150'); - expect(lastCall[1].text).toContain('200'); - }); - - it('should maintain state between animations', () => { - // First animation - run to completion - service.animateTokens(100, 200); - - // Simulate animation to completion - currentTime = 0; - const startTime = currentTime; - let iterations = 0; - while (currentTime - startTime < 1000 && iterations < 100) { - const callbackIndex = rafCallbacks.length - 1; - if (callbackIndex >= 0) { - rafCallbacks[callbackIndex](currentTime); - } - currentTime += 16; - iterations++; - } - - // Final frame at exactly 1000ms - currentTime = startTime + 1000; - const finalIndex = rafCallbacks.length - 1; - if (finalIndex >= 0) { - rafCallbacks[finalIndex](currentTime); - } - - // Second animation should start from previous end values - rafCallbacks = []; // Reset RAF tracking - service.animateTokens(200, 400); - - // Simulate second animation to completion - currentTime = performance.now(); - const secondStartTime = currentTime; - iterations = 0; - while (currentTime - secondStartTime < 1000 && iterations < 100) { - const callbackIndex = rafCallbacks.length - 1; - if (callbackIndex >= 0) { - rafCallbacks[callbackIndex](currentTime); - } - currentTime += 16; - iterations++; - } - - // Final frame at exactly 1000ms - currentTime = secondStartTime + 1000; - const secondFinalIndex = rafCallbacks.length - 1; - if (secondFinalIndex >= 0) { - rafCallbacks[secondFinalIndex](currentTime); - } - - const lastCall = mockStatusBarItem.createEl.mock.calls[mockStatusBarItem.createEl.mock.calls.length - 1]; - expect(lastCall[1].text).toBe('Input Tokens: 200 / Output Tokens: 400'); - }); - }); -}); diff --git a/package.json b/package.json index cd90450..8530289 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "diff2html": "^3.4.52", "express": "^5.2.1", "fuzzysort": "^3.1.0", - "gpt-tokenizer": "^3.4.0", "highlight.js": "^11.11.1", "katex": "^0.16.27", "lowlight": "^3.3.0",