diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index b7df958e..4f30e3da 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { render } from 'ink'; -import { App } from './ui/App.js'; +import { AppWrapper } from './ui/App.js'; import { loadCliConfig } from './config/config.js'; import { readStdin } from './utils/readStdin.js'; import { sandbox_command, start_sandbox } from './utils/sandbox.js'; @@ -95,7 +95,7 @@ export async function main() { if (process.stdin.isTTY && input?.length === 0) { render( - { +export const AppWrapper = (props: AppProps) => ( + + + +); + +const App = ({ config, settings, startupWarnings = [] }: AppProps) => { const { history, addItem, clearItems } = useHistory(); const { consoleMessages, diff --git a/packages/cli/src/ui/contexts/SessionContext.test.tsx b/packages/cli/src/ui/contexts/SessionContext.test.tsx new file mode 100644 index 00000000..3b5454cf --- /dev/null +++ b/packages/cli/src/ui/contexts/SessionContext.test.tsx @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import { SessionProvider, useSession } from './SessionContext.js'; +import { describe, it, expect } from 'vitest'; + +const TestComponent = () => { + const { startTime } = useSession(); + return {startTime.toISOString()}; +}; + +describe('SessionContext', () => { + it('should provide a start time', () => { + const { lastFrame } = render( + + + , + ); + + const frameText = lastFrame(); + // Check if the output is a valid ISO string, which confirms it's a Date object. + expect(new Date(frameText!).toString()).not.toBe('Invalid Date'); + }); +}); diff --git a/packages/cli/src/ui/contexts/SessionContext.tsx b/packages/cli/src/ui/contexts/SessionContext.tsx new file mode 100644 index 00000000..c511aa46 --- /dev/null +++ b/packages/cli/src/ui/contexts/SessionContext.tsx @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { createContext, useContext, useState, useMemo } from 'react'; + +interface SessionContextType { + startTime: Date; +} + +const SessionContext = createContext(null); + +export const SessionProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [startTime] = useState(new Date()); + + const value = useMemo( + () => ({ + startTime, + }), + [startTime], + ); + + return ( + {children} + ); +}; + +export const useSession = () => { + const context = useContext(SessionContext); + if (!context) { + throw new Error('useSession must be used within a SessionProvider'); + } + return context; +}; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 221893a2..3fcdff97 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -61,10 +61,15 @@ import { MCPServerStatus, getMCPServerStatus, } from '@gemini-cli/core'; +import { useSession } from '../contexts/SessionContext.js'; import * as ShowMemoryCommandModule from './useShowMemoryCommand.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; +vi.mock('../contexts/SessionContext.js', () => ({ + useSession: vi.fn(), +})); + vi.mock('./useShowMemoryCommand.js', () => ({ SHOW_MEMORY_COMMAND_NAME: '/memory show', createShowMemoryAction: vi.fn(() => vi.fn()), @@ -84,6 +89,7 @@ describe('useSlashCommandProcessor', () => { let mockPerformMemoryRefresh: ReturnType; let mockConfig: Config; let mockCorgiMode: ReturnType; + const mockUseSession = useSession as Mock; beforeEach(() => { mockAddItem = vi.fn(); @@ -99,6 +105,9 @@ describe('useSlashCommandProcessor', () => { getModel: vi.fn(() => 'test-model'), } as unknown as Config; mockCorgiMode = vi.fn(); + mockUseSession.mockReturnValue({ + startTime: new Date('2025-01-01T00:00:00.000Z'), + }); (open as Mock).mockClear(); mockProcessExit.mockClear(); @@ -230,6 +239,34 @@ describe('useSlashCommandProcessor', () => { }); }); + describe('/stats command', () => { + it('should show the session duration', async () => { + const { handleSlashCommand } = getProcessor(); + let commandResult: SlashCommandActionReturn | boolean = false; + + // Mock current time + const mockDate = new Date('2025-01-01T00:01:05.000Z'); + vi.setSystemTime(mockDate); + + await act(async () => { + commandResult = handleSlashCommand('/stats'); + }); + + expect(mockAddItem).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: MessageType.INFO, + text: 'Session duration: 1m 5s', + }), + expect.any(Number), + ); + expect(commandResult).toBe(true); + + // Restore system time + vi.useRealTimers(); + }); + }); + describe('Other commands', () => { it('/help should open help and return true', async () => { const { handleSlashCommand } = getProcessor(); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 38fdddba..85ae825e 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -11,6 +11,7 @@ import process from 'node:process'; import { UseHistoryManagerReturn } from './useHistoryManager.js'; import { Config, MCPServerStatus, getMCPServerStatus } from '@gemini-cli/core'; import { Message, MessageType, HistoryItemWithoutId } from '../types.js'; +import { useSession } from '../contexts/SessionContext.js'; import { createShowMemoryAction } from './useShowMemoryCommand.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { formatMemoryUsage } from '../utils/formatters.js'; @@ -49,6 +50,8 @@ export const useSlashCommandProcessor = ( toggleCorgiMode: () => void, showToolDescriptions: boolean = false, ) => { + const session = useSession(); + const addMessage = useCallback( (message: Message) => { // Convert Message to HistoryItemWithoutId @@ -138,6 +141,33 @@ export const useSlashCommandProcessor = ( openThemeDialog(); }, }, + { + name: 'stats', + altName: 'usage', + description: 'check session stats', + action: (_mainCommand, _subCommand, _args) => { + const now = new Date(); + const duration = now.getTime() - session.startTime.getTime(); + const durationInSeconds = Math.floor(duration / 1000); + const hours = Math.floor(durationInSeconds / 3600); + const minutes = Math.floor((durationInSeconds % 3600) / 60); + const seconds = durationInSeconds % 60; + + const durationString = [ + hours > 0 ? `${hours}h` : '', + minutes > 0 ? `${minutes}m` : '', + `${seconds}s`, + ] + .filter(Boolean) + .join(' '); + + addMessage({ + type: MessageType.INFO, + content: `Session duration: ${durationString}`, + timestamp: new Date(), + }); + }, + }, { name: 'mcp', description: 'list configured MCP servers and tools', @@ -447,6 +477,7 @@ Add any other context about the problem here. toggleCorgiMode, config, showToolDescriptions, + session.startTime, ], );