summaryrefslogtreecommitdiff
path: root/packages/cli
diff options
context:
space:
mode:
authorHarold Mciver <[email protected]>2025-07-15 16:10:04 -0400
committerGitHub <[email protected]>2025-07-15 20:10:04 +0000
commit03b3917f62e5db2b00bcb27df9d78ee5289d9a85 (patch)
treedc6007c213bbdef4a39f1627d7c3d59a3bf44a23 /packages/cli
parent8d9dc44b71c71dc7800f1dedcc5f88ec1ab654fa (diff)
updated `/stats` to use new slash command arch (#4146)
Diffstat (limited to 'packages/cli')
-rw-r--r--packages/cli/src/services/CommandService.test.ts14
-rw-r--r--packages/cli/src/services/CommandService.ts2
-rw-r--r--packages/cli/src/ui/commands/statsCommand.test.ts78
-rw-r--r--packages/cli/src/ui/commands/statsCommand.ts63
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.test.ts81
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.ts30
6 files changed, 163 insertions, 105 deletions
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<SlashCommand[]> => [
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
@@ -248,36 +248,6 @@ export const useSlashCommandProcessor = (
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',
action: async (_mainCommand, _subCommand, _args) => {