feat: Introduce session context and add session duration stat for `/stats` command (#854)
This commit is contained in:
parent
9104ac02f7
commit
7868ef8229
|
@ -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}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
};
|
|
@ -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();
|
||||||
|
|
|
@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue