From 076071e5ec9df0e44f2f7626bdeb117ffbd6df2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Mon, 22 Jun 2026 10:54:21 +0800 Subject: [PATCH] feat(cli): add optional [HH:MM:SS] timestamp before each assistant turn --- packages/cli/src/config/settingsSchema.ts | 10 ++ .../ui/components/HistoryItemDisplay.test.tsx | 76 ++++++++++ .../src/ui/components/HistoryItemDisplay.tsx | 33 +++-- .../src/ui/components/SessionPreview.test.tsx | 135 ++++++++---------- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 8 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 83 ++++++++--- packages/cli/src/ui/types.ts | 1 + .../schemas/settings.schema.json | 5 + 8 files changed, 240 insertions(+), 111 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b0a10ac0aaf..45d7f3eb1da 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -570,6 +570,16 @@ const SETTINGS_SCHEMA = { { value: 'json', label: 'JSON' }, ], }, + showTimestamps: { + type: 'boolean', + label: 'Show Timestamps', + category: 'General', + requiresRestart: false, + default: false, + description: + 'Show [HH:MM:SS] timestamp before each assistant response.', + showInDialog: true, + }, }, }, diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 987d0b2be45..a53c6e0a2fb 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -15,6 +15,7 @@ import type { } from '@qwen-code/qwen-code-core'; import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; import { renderWithProviders } from '../../test-utils/render.js'; +import { LoadedSettings } from '../../config/settings.js'; import { ConfigContext } from '../contexts/ConfigContext.js'; import { CompactModeProvider } from '../contexts/CompactModeContext.js'; @@ -406,4 +407,79 @@ describe('', () => { expect(lastFrame()).not.toContain('Continuing the reasoning'); }); + + describe('showTimestamps', () => { + const timestampItem: HistoryItem = { + ...baseItem, + type: 'gemini', + text: 'Hello from assistant', + timestamp: new Date('2026-01-15T14:30:45').getTime(), + }; + + const makeTimestampSettings = () => + new LoadedSettings( + { path: '', settings: {}, originalSettings: {} }, + { path: '', settings: {}, originalSettings: {} }, + { + path: '', + settings: { output: { showTimestamps: true } }, + originalSettings: {}, + }, + { path: '', settings: {}, originalSettings: {} }, + true, + new Set(), + ); + + it('does not render timestamp when showTimestamps is disabled', () => { + const { lastFrame } = renderWithProviders( + , + ); + expect(lastFrame()).not.toMatch(/\[\d{2}:\d{2}:\d{2}\]/); + }); + + it('renders [HH:MM:SS] timestamp when showTimestamps is enabled', () => { + const { lastFrame } = renderWithProviders( + , + { settings: makeTimestampSettings() }, + ); + expect(lastFrame()).toMatch(/\[\d{2}:\d{2}:\d{2}\]/); + }); + + it('renders timestamp even when isPending is true (streaming)', () => { + const { lastFrame } = renderWithProviders( + , + { settings: makeTimestampSettings() }, + ); + expect(lastFrame()).toMatch(/\[\d{2}:\d{2}:\d{2}\]/); + }); + + it('does not render timestamp when timestamp field is missing', () => { + const noTimestampItem: HistoryItem = { + id: 1, + type: 'gemini', + text: 'Hello', + }; + const { lastFrame } = renderWithProviders( + , + { settings: makeTimestampSettings() }, + ); + expect(lastFrame()).not.toMatch(/\[\d{2}:\d{2}:\d{2}\]/); + }); + }); }); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index d9f49cd73ea..bf87fe163b8 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -57,6 +57,7 @@ import { MemorySavedMessage } from './messages/MemorySavedMessage.js'; import { DiffStatsDisplay } from './messages/DiffStatsDisplay.js'; import { GoalStatusMessage } from './messages/GoalStatusMessage.js'; import { useCompactMode } from '../contexts/CompactModeContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -137,6 +138,9 @@ const HistoryItemDisplayComponent: React.FC = ({ const marginTop = getHistoryItemMarginTop(item); const { compactMode } = useCompactMode(); + const settings = useSettings(); + const showTimestamps = settings.merged.output?.showTimestamps === true; + const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); const contentWidth = terminalWidth - 4; const boxWidth = mainAreaWidth || contentWidth; @@ -160,15 +164,26 @@ const HistoryItemDisplayComponent: React.FC = ({ )} {itemForDisplay.type === 'gemini' && ( - + <> + {showTimestamps && itemForDisplay.timestamp != null && ( + + [ + {new Date(itemForDisplay.timestamp).toLocaleTimeString('en-US', { + hour12: false, + })} + ] + + )} + + )} {itemForDisplay.type === 'gemini_content' && ( { @@ -83,32 +82,28 @@ function fakeResumedData( describe('SessionPreview', () => { it('shows loading state before data arrives', () => { const svc = mockService(new Promise(() => {})); // never resolves - const { lastFrame } = render( - - - , + const { lastFrame } = renderWithProviders( + , ); expect(lastFrame()).toContain('Loading session preview'); }); it('renders all messages after load', async () => { const svc = mockService(fakeResumedData()); - const { lastFrame } = render( - - - , + const { lastFrame } = renderWithProviders( + , ); await wait(100); const frame = lastFrame() ?? ''; @@ -127,16 +122,14 @@ describe('SessionPreview', () => { { text: 'FINAL-ANSWER-MARKER' }, ]), ); - const { lastFrame } = render( - - - , + const { lastFrame } = renderWithProviders( + , ); await wait(100); const frame = lastFrame() ?? ''; @@ -151,19 +144,17 @@ describe('SessionPreview', () => { it('renders footer metadata (messageCount · time · branch)', async () => { const svc = mockService(fakeResumedData()); - const { lastFrame } = render( - - - , + const { lastFrame } = renderWithProviders( + , ); await wait(100); const frame = lastFrame() ?? ''; @@ -176,16 +167,14 @@ describe('SessionPreview', () => { // through to SessionPreview. The footer must still show a count, derived // from the loaded ResumedSessionData using unique user/assistant UUIDs. const svc = mockService(fakeResumedData()); - const { lastFrame } = render( - - - , + const { lastFrame } = renderWithProviders( + , ); await wait(100); const frame = lastFrame() ?? ''; @@ -196,16 +185,14 @@ describe('SessionPreview', () => { it('calls onExit when Escape is pressed', async () => { const onExit = vi.fn(); const svc = mockService(fakeResumedData()); - const { stdin } = render( - - - , + const { stdin } = renderWithProviders( + , ); await wait(100); stdin.write('\u001B'); // ESC @@ -216,16 +203,14 @@ describe('SessionPreview', () => { it('calls onResume(sessionId) when Enter is pressed', async () => { const onResume = vi.fn(); const svc = mockService(fakeResumedData()); - const { stdin } = render( - - - , + const { stdin } = renderWithProviders( + , ); await wait(100); stdin.write('\r'); // Enter diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index b13b3b24a63..c01de0142f5 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -3759,10 +3759,10 @@ describe('useGeminiStream', () => { }); expect(mockAddItem).toHaveBeenCalledWith( - { + expect.objectContaining({ type: 'gemini', text: 'Initial', - }, + }), expect.any(Number), ); @@ -6695,10 +6695,10 @@ describe('useGeminiStream', () => { }); expect(mockAddItem).toHaveBeenCalledWith( - { + expect.objectContaining({ type: 'gemini', text: 'First call content', - }, + }), expect.any(Number), ); expect(mainAbortSignal?.aborted).toBe(true); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index a5bf15f6576..33be9fcf2f0 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -64,6 +64,7 @@ import type { HistoryItemGoalStatus, HistoryItemWithoutId, HistoryItemToolGroup, + HistoryItemGemini, SlashCommandProcessorResult, } from '../types.js'; import { StreamingState, MessageType, ToolCallStatus } from '../types.js'; @@ -399,6 +400,20 @@ export const useGeminiStream = ( // alongside lastTurnUserItemRef. const turnSawContentEventRef = useRef(false); const lastPromptErroredRef = useRef(false); + + // Wrapper around addItem that attaches timestamp to gemini items for display. + // Only 'gemini' (new assistant turn) gets a timestamp; 'gemini_content' + // (same turn, performance-split continuation) does not. + const commitItem = useCallback( + (item: HistoryItemWithoutId, userMessageTimestamp: number): number => { + if (item.type === 'gemini' && !(item as HistoryItemGemini).timestamp) { + (item as HistoryItemGemini).timestamp = Date.now(); + } + return addItem(item, userMessageTimestamp); + }, + [addItem], + ); + const dualOutput = useDualOutput(); const [isResponding, setIsResponding] = useState(false); // React state can lag by one render; this tracks the actual stream lifetime. @@ -691,7 +706,7 @@ export const useGeminiStream = ( // events that arrived inside the throttle window // (STREAM_UPDATE_THROTTLE_MS), making AppContainer's auto-restore // wrongly conclude the model produced nothing — and the subsequent - // addItem(pendingHistoryItemRef.current) below would commit content + // commitItem(pendingHistoryItemRef.current) below would commit content // that auto-restore then truncates away. for (const flushBufferedStreamEvents of flushBufferedStreamEventsRef.current) { flushBufferedStreamEvents(); @@ -728,7 +743,7 @@ export const useGeminiStream = ( logApiCancel(config, cancellationEvent); if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, Date.now()); + commitItem(pendingHistoryItemRef.current, Date.now()); } addItem( { @@ -766,6 +781,7 @@ export const useGeminiStream = ( }, [ streamingState, addItem, + commitItem, setPendingHistoryItem, onCancelSubmit, pendingHistoryItemRef, @@ -975,9 +991,13 @@ export const useGeminiStream = ( return newGeminiMessageBuffer; } if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); + commitItem(pendingHistoryItemRef.current, userMessageTimestamp); } - setPendingHistoryItem({ type: 'gemini', text: '' }); + setPendingHistoryItem({ + type: 'gemini', + text: '', + timestamp: Date.now(), + }); newGeminiMessageBuffer = stripLeadingBlankLines(newGeminiMessageBuffer); } // Split large messages for better rendering performance. Ideally, @@ -1005,7 +1025,7 @@ export const useGeminiStream = ( // broken up so that there are more "statically" rendered. const beforeText = newGeminiMessageBuffer.substring(0, safeSplitPoint); const afterText = newGeminiMessageBuffer.substring(safeSplitPoint); - addItem( + commitItem( { type: nextPendingType, text: beforeText, @@ -1016,13 +1036,21 @@ export const useGeminiStream = ( newGeminiMessageBuffer = afterText; } // Update the existing message with accumulated content. - setPendingHistoryItem({ - type: nextPendingType, - text: newGeminiMessageBuffer, + setPendingHistoryItem((item) => { + const base: HistoryItemWithoutId = { + type: nextPendingType, + text: newGeminiMessageBuffer, + }; + if (item && 'timestamp' in item) { + (base as HistoryItemGemini).timestamp = ( + item as HistoryItemGemini + ).timestamp; + } + return base; }); return newGeminiMessageBuffer; }, - [addItem, pendingHistoryItemRef, setPendingHistoryItem], + [commitItem, pendingHistoryItemRef, setPendingHistoryItem], ); const mergeThought = useCallback( @@ -1200,7 +1228,7 @@ export const useGeminiStream = ( }; addItem(pendingItem, userMessageTimestamp); } else { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); + commitItem(pendingHistoryItemRef.current, userMessageTimestamp); } setPendingHistoryItem(null); } @@ -1215,6 +1243,7 @@ export const useGeminiStream = ( [ addItem, commitPendingThought, + commitItem, pendingHistoryItemRef, setPendingHistoryItem, setThought, @@ -1228,7 +1257,7 @@ export const useGeminiStream = ( // Persist any streamed reasoning (collapsed) above the error. commitPendingThought(userMessageTimestamp); if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); + commitItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); } // Only show Ctrl+Y hint if not already showing an auto-retry countdown @@ -1268,8 +1297,8 @@ export const useGeminiStream = ( }); }, [ - addItem, commitPendingThought, + commitItem, pendingHistoryItemRef, setPendingHistoryItem, setPendingRetryErrorItem, @@ -1286,12 +1315,18 @@ export const useGeminiStream = ( } if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); + commitItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); } addItem({ type: MessageType.INFO, text }, userMessageTimestamp); }, - [addItem, pendingHistoryItemRef, setPendingHistoryItem, settings], + [ + addItem, + commitItem, + pendingHistoryItemRef, + setPendingHistoryItem, + settings, + ], ); const handleFinishedEvent = useCallback( @@ -1354,7 +1389,7 @@ export const useGeminiStream = ( userMessageTimestamp: number, ) => { if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); + commitItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); } const reasonClause = @@ -1373,7 +1408,7 @@ export const useGeminiStream = ( Date.now(), ); }, - [addItem, config, pendingHistoryItemRef, setPendingHistoryItem], + [addItem, commitItem, config, pendingHistoryItemRef, setPendingHistoryItem], ); const handleMaxSessionTurnsEvent = useCallback( @@ -1446,7 +1481,7 @@ export const useGeminiStream = ( userMessageTimestamp: number, ) => { if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); + commitItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); } addItem( @@ -1458,7 +1493,7 @@ export const useGeminiStream = ( userMessageTimestamp, ); }, - [addItem, pendingHistoryItemRef, setPendingHistoryItem], + [addItem, commitItem, pendingHistoryItemRef, setPendingHistoryItem], ); const handleStopHookLoopEvent = useCallback( @@ -1471,7 +1506,7 @@ export const useGeminiStream = ( userMessageTimestamp: number, ) => { if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); + commitItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); } // When the active loop is driven by `/goal`, replace the generic @@ -1502,7 +1537,7 @@ export const useGeminiStream = ( userMessageTimestamp, ); }, - [addItem, config, pendingHistoryItemRef, setPendingHistoryItem], + [addItem, commitItem, config, pendingHistoryItemRef, setPendingHistoryItem], ); const handleActiveGoalEvent = useCallback( @@ -1712,7 +1747,7 @@ export const useGeminiStream = ( // as "t" → "te" → "tes" cumulative rendering even though each // turn is persisted as a clean, separate assistant message. if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); + commitItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); } geminiMessageBuffer = ''; @@ -1768,7 +1803,7 @@ export const useGeminiStream = ( // Display system message from Stop hooks with "Stop says:" prefix // First commit any pending AI response to ensure correct ordering if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); + commitItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); } addItem( @@ -1887,6 +1922,7 @@ export const useGeminiStream = ( handleStopHookLoopEvent, handleActiveGoalEvent, addItem, + commitItem, dualOutput, ], ); @@ -2112,7 +2148,7 @@ export const useGeminiStream = ( } if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); + commitItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); } @@ -2204,6 +2240,7 @@ export const useGeminiStream = ( processGeminiStreamEvents, pendingHistoryItemRef, addItem, + commitItem, setPendingHistoryItem, setInitError, geminiClient, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index da81500cb32..ea58b345ae3 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -126,6 +126,7 @@ export type HistoryItemUser = HistoryItemBase & { export type HistoryItemGemini = HistoryItemBase & { type: 'gemini'; text: string; + timestamp?: number; }; export type HistoryItemGeminiContent = HistoryItemBase & { diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 4a22690a3dd..ac682997408 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -158,6 +158,11 @@ "json" ], "default": "text" + }, + "showTimestamps": { + "description": "Show [HH:MM:SS] timestamp before each assistant response.", + "type": "boolean", + "default": false } } },