feat: Introduce session context and add session duration stat for `/stats` command (#854)

This commit is contained in:
Abhi 2025-06-08 18:01:02 -04:00 committed by GitHub
parent 9104ac02f7
commit 7868ef8229
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 146 additions and 4 deletions

View File

@ -6,7 +6,7 @@
import React from 'react'; import React from 'react';
import { render } from 'ink'; import { render } from 'ink';
import { App } from './ui/App.js'; import { AppWrapper } from './ui/App.js';
import { loadCliConfig } from './config/config.js'; import { loadCliConfig } from './config/config.js';
import { readStdin } from './utils/readStdin.js'; import { readStdin } from './utils/readStdin.js';
import { sandbox_command, start_sandbox } from './utils/sandbox.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) { if (process.stdin.isTTY && input?.length === 0) {
render( render(
<React.StrictMode> <React.StrictMode>
<App <AppWrapper
config={config} config={config}
settings={settings} settings={settings}
startupWarnings={startupWarnings} startupWarnings={startupWarnings}

View File

@ -6,7 +6,7 @@
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { render } from 'ink-testing-library'; import { render } from 'ink-testing-library';
import { App } from './App.js'; import { AppWrapper as App } from './App.js';
import { import {
Config as ServerConfig, Config as ServerConfig,
MCPServerConfig, MCPServerConfig,

View File

@ -48,6 +48,7 @@ import {
} from '@gemini-cli/core'; } from '@gemini-cli/core';
import { useLogger } from './hooks/useLogger.js'; import { useLogger } from './hooks/useLogger.js';
import { StreamingContext } from './contexts/StreamingContext.js'; import { StreamingContext } from './contexts/StreamingContext.js';
import { SessionProvider } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useGitBranchName } from './hooks/useGitBranchName.js';
const CTRL_C_PROMPT_DURATION_MS = 1000; const CTRL_C_PROMPT_DURATION_MS = 1000;
@ -58,7 +59,13 @@ interface AppProps {
startupWarnings?: string[]; startupWarnings?: string[];
} }
export const App = ({ config, settings, startupWarnings = [] }: AppProps) => { export const AppWrapper = (props: AppProps) => (
<SessionProvider>
<App {...props} />
</SessionProvider>
);
const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
const { history, addItem, clearItems } = useHistory(); const { history, addItem, clearItems } = useHistory();
const { const {
consoleMessages, consoleMessages,

View File

@ -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 <Text>{startTime.toISOString()}</Text>;
};
describe('SessionContext', () => {
it('should provide a start time', () => {
const { lastFrame } = render(
<SessionProvider>
<TestComponent />
</SessionProvider>,
);
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');
});
});

View File

@ -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<SessionContextType | null>(null);
export const SessionProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [startTime] = useState(new Date());
const value = useMemo(
() => ({
startTime,
}),
[startTime],
);
return (
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
);
};
export const useSession = () => {
const context = useContext(SessionContext);
if (!context) {
throw new Error('useSession must be used within a SessionProvider');
}
return context;
};

View File

@ -61,10 +61,15 @@ import {
MCPServerStatus, MCPServerStatus,
getMCPServerStatus, getMCPServerStatus,
} from '@gemini-cli/core'; } from '@gemini-cli/core';
import { useSession } from '../contexts/SessionContext.js';
import * as ShowMemoryCommandModule from './useShowMemoryCommand.js'; import * as ShowMemoryCommandModule from './useShowMemoryCommand.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
vi.mock('../contexts/SessionContext.js', () => ({
useSession: vi.fn(),
}));
vi.mock('./useShowMemoryCommand.js', () => ({ vi.mock('./useShowMemoryCommand.js', () => ({
SHOW_MEMORY_COMMAND_NAME: '/memory show', SHOW_MEMORY_COMMAND_NAME: '/memory show',
createShowMemoryAction: vi.fn(() => vi.fn()), createShowMemoryAction: vi.fn(() => vi.fn()),
@ -84,6 +89,7 @@ describe('useSlashCommandProcessor', () => {
let mockPerformMemoryRefresh: ReturnType<typeof vi.fn>; let mockPerformMemoryRefresh: ReturnType<typeof vi.fn>;
let mockConfig: Config; let mockConfig: Config;
let mockCorgiMode: ReturnType<typeof vi.fn>; let mockCorgiMode: ReturnType<typeof vi.fn>;
const mockUseSession = useSession as Mock;
beforeEach(() => { beforeEach(() => {
mockAddItem = vi.fn(); mockAddItem = vi.fn();
@ -99,6 +105,9 @@ describe('useSlashCommandProcessor', () => {
getModel: vi.fn(() => 'test-model'), getModel: vi.fn(() => 'test-model'),
} as unknown as Config; } as unknown as Config;
mockCorgiMode = vi.fn(); mockCorgiMode = vi.fn();
mockUseSession.mockReturnValue({
startTime: new Date('2025-01-01T00:00:00.000Z'),
});
(open as Mock).mockClear(); (open as Mock).mockClear();
mockProcessExit.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', () => { describe('Other commands', () => {
it('/help should open help and return true', async () => { it('/help should open help and return true', async () => {
const { handleSlashCommand } = getProcessor(); const { handleSlashCommand } = getProcessor();

View File

@ -11,6 +11,7 @@ import process from 'node:process';
import { UseHistoryManagerReturn } from './useHistoryManager.js'; import { UseHistoryManagerReturn } from './useHistoryManager.js';
import { Config, MCPServerStatus, getMCPServerStatus } from '@gemini-cli/core'; import { Config, MCPServerStatus, getMCPServerStatus } from '@gemini-cli/core';
import { Message, MessageType, HistoryItemWithoutId } from '../types.js'; import { Message, MessageType, HistoryItemWithoutId } from '../types.js';
import { useSession } from '../contexts/SessionContext.js';
import { createShowMemoryAction } from './useShowMemoryCommand.js'; import { createShowMemoryAction } from './useShowMemoryCommand.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js'; import { formatMemoryUsage } from '../utils/formatters.js';
@ -49,6 +50,8 @@ export const useSlashCommandProcessor = (
toggleCorgiMode: () => void, toggleCorgiMode: () => void,
showToolDescriptions: boolean = false, showToolDescriptions: boolean = false,
) => { ) => {
const session = useSession();
const addMessage = useCallback( const addMessage = useCallback(
(message: Message) => { (message: Message) => {
// Convert Message to HistoryItemWithoutId // Convert Message to HistoryItemWithoutId
@ -138,6 +141,33 @@ export const useSlashCommandProcessor = (
openThemeDialog(); 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', name: 'mcp',
description: 'list configured MCP servers and tools', description: 'list configured MCP servers and tools',
@ -447,6 +477,7 @@ Add any other context about the problem here.
toggleCorgiMode, toggleCorgiMode,
config, config,
showToolDescriptions, showToolDescriptions,
session.startTime,
], ],
); );