From 03b3917f62e5db2b00bcb27df9d78ee5289d9a85 Mon Sep 17 00:00:00 2001 From: Harold Mciver Date: Tue, 15 Jul 2025 16:10:04 -0400 Subject: [PATCH] updated `/stats` to use new slash command arch (#4146) --- .../cli/src/services/CommandService.test.ts | 14 +++- packages/cli/src/services/CommandService.ts | 2 + .../cli/src/ui/commands/statsCommand.test.ts | 78 ++++++++++++++++++ packages/cli/src/ui/commands/statsCommand.ts | 63 +++++++++++++++ .../ui/hooks/slashCommandProcessor.test.ts | 81 +++---------------- .../cli/src/ui/hooks/slashCommandProcessor.ts | 30 ------- 6 files changed, 163 insertions(+), 105 deletions(-) create mode 100644 packages/cli/src/ui/commands/statsCommand.test.ts create mode 100644 packages/cli/src/ui/commands/statsCommand.ts diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index e780ec5f..bbee13fc 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -12,6 +12,7 @@ import { helpCommand } from '../ui/commands/helpCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; +import { statsCommand } from '../ui/commands/statsCommand.js'; import { privacyCommand } from '../ui/commands/privacyCommand.js'; import { aboutCommand } from '../ui/commands/aboutCommand.js'; @@ -34,6 +35,9 @@ vi.mock('../ui/commands/themeCommand.js', () => ({ vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: { name: 'privacy', description: 'Mock Privacy' }, })); +vi.mock('../ui/commands/statsCommand.js', () => ({ + statsCommand: { name: 'stats', description: 'Mock Stats' }, +})); vi.mock('../ui/commands/aboutCommand.js', () => ({ aboutCommand: { name: 'about', description: 'Mock About' }, })); @@ -62,7 +66,7 @@ describe('CommandService', () => { const tree = commandService.getCommands(); // Post-condition assertions - expect(tree.length).toBe(7); + expect(tree.length).toBe(8); const commandNames = tree.map((cmd) => cmd.name); expect(commandNames).toContain('auth'); @@ -70,6 +74,7 @@ describe('CommandService', () => { expect(commandNames).toContain('help'); expect(commandNames).toContain('clear'); expect(commandNames).toContain('theme'); + expect(commandNames).toContain('stats'); expect(commandNames).toContain('privacy'); expect(commandNames).toContain('about'); }); @@ -77,14 +82,14 @@ describe('CommandService', () => { it('should overwrite any existing commands when called again', async () => { // Load once await commandService.loadCommands(); - expect(commandService.getCommands().length).toBe(7); + expect(commandService.getCommands().length).toBe(8); // Load again await commandService.loadCommands(); const tree = commandService.getCommands(); // Should not append, but overwrite - expect(tree.length).toBe(7); + expect(tree.length).toBe(8); }); }); @@ -96,7 +101,7 @@ describe('CommandService', () => { await commandService.loadCommands(); const loadedTree = commandService.getCommands(); - expect(loadedTree.length).toBe(7); + expect(loadedTree.length).toBe(8); expect(loadedTree).toEqual([ aboutCommand, authCommand, @@ -104,6 +109,7 @@ describe('CommandService', () => { helpCommand, memoryCommand, privacyCommand, + statsCommand, themeCommand, ]); }); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index ef31952d..cc7b4e62 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -10,6 +10,7 @@ import { helpCommand } from '../ui/commands/helpCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; +import { statsCommand } from '../ui/commands/statsCommand.js'; import { privacyCommand } from '../ui/commands/privacyCommand.js'; import { aboutCommand } from '../ui/commands/aboutCommand.js'; @@ -20,6 +21,7 @@ const loadBuiltInCommands = async (): Promise => [ helpCommand, memoryCommand, privacyCommand, + statsCommand, themeCommand, ]; diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts new file mode 100644 index 00000000..485fcf69 --- /dev/null +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { statsCommand } from './statsCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; +import { formatDuration } from '../utils/formatters.js'; + +describe('statsCommand', () => { + let mockContext: CommandContext; + const startTime = new Date('2025-07-14T10:00:00.000Z'); + const endTime = new Date('2025-07-14T10:00:30.000Z'); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(endTime); + + // 1. Create the mock context with all default values + mockContext = createMockCommandContext(); + + // 2. Directly set the property on the created mock context + mockContext.session.stats.sessionStartTime = startTime; + }); + + it('should display general session stats when run with no subcommand', () => { + if (!statsCommand.action) throw new Error('Command has no action'); + + statsCommand.action(mockContext, ''); + + const expectedDuration = formatDuration( + endTime.getTime() - startTime.getTime(), + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.STATS, + duration: expectedDuration, + }, + expect.any(Number), + ); + }); + + it('should display model stats when using the "model" subcommand', () => { + const modelSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'model', + ); + if (!modelSubCommand?.action) throw new Error('Subcommand has no action'); + + modelSubCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.MODEL_STATS, + }, + expect.any(Number), + ); + }); + + it('should display tool stats when using the "tools" subcommand', () => { + const toolsSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'tools', + ); + if (!toolsSubCommand?.action) throw new Error('Subcommand has no action'); + + toolsSubCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.TOOL_STATS, + }, + expect.any(Number), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts new file mode 100644 index 00000000..87e902d4 --- /dev/null +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MessageType, HistoryItemStats } from '../types.js'; +import { formatDuration } from '../utils/formatters.js'; +import { type CommandContext, type SlashCommand } from './types.js'; + +export const statsCommand: SlashCommand = { + name: 'stats', + altName: 'usage', + description: 'check session stats. Usage: /stats [model|tools]', + action: (context: CommandContext) => { + const now = new Date(); + const { sessionStartTime } = context.session.stats; + if (!sessionStartTime) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: 'Session start time is unavailable, cannot calculate stats.', + }, + Date.now(), + ); + return; + } + const wallDuration = now.getTime() - sessionStartTime.getTime(); + + const statsItem: HistoryItemStats = { + type: MessageType.STATS, + duration: formatDuration(wallDuration), + }; + + context.ui.addItem(statsItem, Date.now()); + }, + subCommands: [ + { + name: 'model', + description: 'Show model-specific usage statistics.', + action: (context: CommandContext) => { + context.ui.addItem( + { + type: MessageType.MODEL_STATS, + }, + Date.now(), + ); + }, + }, + { + name: 'tools', + description: 'Show tool-specific usage statistics.', + action: (context: CommandContext) => { + context.ui.addItem( + { + type: MessageType.TOOL_STATS, + }, + Date.now(), + ); + }, + }, + ], +}; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 6946bde0..f39795c0 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -54,7 +54,16 @@ vi.mock('../../utils/version.js', () => ({ })); import { act, renderHook } from '@testing-library/react'; -import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest'; +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + beforeAll, + Mock, +} from 'vitest'; import open from 'open'; import { useSlashCommandProcessor } from './slashCommandProcessor.js'; import { MessageType, SlashCommandProcessorResult } from '../types.js'; @@ -207,76 +216,6 @@ describe('useSlashCommandProcessor', () => { const getProcessor = (showToolDescriptions: boolean = false) => getProcessorHook(showToolDescriptions).result.current; - describe('/stats command', () => { - it('should show detailed session statistics', async () => { - // Arrange - mockUseSessionStats.mockReturnValue({ - stats: { - sessionStartTime: new Date('2025-01-01T00:00:00.000Z'), - }, - }); - - const { handleSlashCommand } = getProcessor(); - const mockDate = new Date('2025-01-01T01:02:03.000Z'); // 1h 2m 3s duration - vi.setSystemTime(mockDate); - - // Act - await act(async () => { - handleSlashCommand('/stats'); - }); - - // Assert - expect(mockAddItem).toHaveBeenNthCalledWith( - 2, // Called after the user message - expect.objectContaining({ - type: MessageType.STATS, - duration: '1h 2m 3s', - }), - expect.any(Number), - ); - - vi.useRealTimers(); - }); - - it('should show model-specific statistics when using /stats model', async () => { - // Arrange - const { handleSlashCommand } = getProcessor(); - - // Act - await act(async () => { - handleSlashCommand('/stats model'); - }); - - // Assert - expect(mockAddItem).toHaveBeenNthCalledWith( - 2, // Called after the user message - expect.objectContaining({ - type: MessageType.MODEL_STATS, - }), - expect.any(Number), - ); - }); - - it('should show tool-specific statistics when using /stats tools', async () => { - // Arrange - const { handleSlashCommand } = getProcessor(); - - // Act - await act(async () => { - handleSlashCommand('/stats tools'); - }); - - // Assert - expect(mockAddItem).toHaveBeenNthCalledWith( - 2, // Called after the user message - expect.objectContaining({ - type: MessageType.TOOL_STATS, - }), - expect.any(Number), - ); - }); - }); - describe('Other commands', () => { it('/editor should open editor dialog and return handled', async () => { const { handleSlashCommand } = getProcessor(); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index e8d773b4..31397af5 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -247,36 +247,6 @@ export const useSlashCommandProcessor = ( description: 'set external editor preference', action: (_mainCommand, _subCommand, _args) => openEditorDialog(), }, - { - name: 'stats', - altName: 'usage', - description: 'check session stats. Usage: /stats [model|tools]', - action: (_mainCommand, subCommand, _args) => { - if (subCommand === 'model') { - addMessage({ - type: MessageType.MODEL_STATS, - timestamp: new Date(), - }); - return; - } else if (subCommand === 'tools') { - addMessage({ - type: MessageType.TOOL_STATS, - timestamp: new Date(), - }); - return; - } - - const now = new Date(); - const { sessionStartTime } = session.stats; - const wallDuration = now.getTime() - sessionStartTime.getTime(); - - addMessage({ - type: MessageType.STATS, - duration: formatDuration(wallDuration), - timestamp: new Date(), - }); - }, - }, { name: 'mcp', description: 'list configured MCP servers and tools',