chore: merge origin/main and resolve JSONL recording conflict

This commit is contained in:
Michael Bleigh
2026-04-09 21:08:42 -07:00
121 changed files with 3399 additions and 2403 deletions
+10
View File
@@ -439,6 +439,16 @@ const SETTINGS_SCHEMA = {
description: 'User interface settings.',
showInDialog: false,
properties: {
debugRainbow: {
type: 'boolean',
label: 'Debug Rainbow',
category: 'UI',
requiresRestart: true,
default: false,
description:
'Enable debug rainbow rendering. Only useful for debugging rendering bugs and performance issues.',
showInDialog: false,
},
theme: {
type: 'string',
label: 'Theme',
+1
View File
@@ -163,6 +163,7 @@ export async function startInteractiveUI(
settings.merged.ui.incrementalRendering !== false &&
useAlternateBuffer &&
!isShpool,
debugRainbow: settings.merged.ui.debugRainbow === true,
},
);
+44 -22
View File
@@ -89,6 +89,7 @@ import {
buildUserSteeringHintPrompt,
logBillingEvent,
ApiKeyUpdatedEvent,
LegacyAgentProtocol,
type InjectionSource,
startMemoryService,
} from '@google/gemini-cli-core';
@@ -118,6 +119,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useAgentStream } from './hooks/useAgentStream.js';
import { type BackgroundTask } from './hooks/useExecutionLifecycle.js';
import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
@@ -1161,6 +1163,46 @@ Logging in with Google... Restarting Gemini CLI to continue.
};
}, [config]);
const streamAgent = useMemo(
() =>
config?.getAgentSessionInteractiveEnabled()
? new LegacyAgentProtocol({ config, getPreferredEditor })
: undefined,
[config, getPreferredEditor],
);
const activeStream = streamAgent
? // eslint-disable-next-line react-hooks/rules-of-hooks
useAgentStream({
agent: streamAgent,
addItem: historyManager.addItem,
onCancelSubmit,
isShellFocused: embeddedShellFocused,
logger,
})
: // eslint-disable-next-line react-hooks/rules-of-hooks
useGeminiStream(
config.getGeminiClient(),
historyManager.history,
historyManager.addItem,
config,
settings,
setDebugMessage,
handleSlashCommand,
shellModeActive,
getPreferredEditor,
onAuthError,
performMemoryRefresh,
modelSwitchedFromQuotaError,
setModelSwitchedFromQuotaError,
onCancelSubmit,
setEmbeddedShellFocused,
terminalWidth,
terminalHeight,
embeddedShellFocused,
consumePendingHints,
);
const {
streamingState,
submitQuery,
@@ -1180,27 +1222,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
backgroundTasks,
dismissBackgroundTask,
retryStatus,
} = useGeminiStream(
config.getGeminiClient(),
historyManager.history,
historyManager.addItem,
config,
settings,
setDebugMessage,
handleSlashCommand,
shellModeActive,
getPreferredEditor,
onAuthError,
performMemoryRefresh,
modelSwitchedFromQuotaError,
setModelSwitchedFromQuotaError,
onCancelSubmit,
setEmbeddedShellFocused,
terminalWidth,
terminalHeight,
embeddedShellFocused,
consumePendingHints,
);
} = activeStream;
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
@@ -1783,7 +1805,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (keyMatchers[Command.QUIT](key)) {
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
// This should happen regardless of the count.
cancelOngoingRequest?.();
void cancelOngoingRequest?.();
handleCtrlCPress();
return true;
@@ -0,0 +1,207 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import type { LegacyAgentProtocol } from '@google/gemini-cli-core';
import { renderHookWithProviders } from '../../test-utils/render.js';
// --- MOCKS ---
const mockLegacyAgentProtocol = vi.hoisted(() => ({
send: vi.fn().mockResolvedValue({ streamId: 'test-stream-id' }),
subscribe: vi.fn().mockReturnValue(() => {}),
abort: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
useSessionStats: vi.fn(() => ({
startNewPrompt: vi.fn(),
})),
};
});
// --- END MOCKS ---
import { useAgentStream } from './useAgentStream.js';
import { MessageType, StreamingState } from '../types.js';
describe('useAgentStream', () => {
const mockAddItem = vi.fn();
const mockOnCancelSubmit = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize on mount', async () => {
await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
addItem: mockAddItem,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
expect(mockLegacyAgentProtocol.subscribe).toHaveBeenCalled();
});
it('should call agent.send when submitQuery is called', async () => {
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
addItem: mockAddItem,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
await act(async () => {
await result.current.submitQuery('hello');
});
expect(mockLegacyAgentProtocol.send).toHaveBeenCalledWith({
message: { content: [{ type: 'text', text: 'hello' }] },
});
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({ type: MessageType.USER, text: 'hello' }),
expect.any(Number),
);
});
it('should update streamingState based on agent_start and agent_end events', async () => {
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
addItem: mockAddItem,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
.calls[0][0];
expect(result.current.streamingState).toBe(StreamingState.Idle);
act(() => {
eventHandler({
type: 'agent_start',
id: '1',
timestamp: '',
streamId: '',
});
});
expect(result.current.streamingState).toBe(StreamingState.Responding);
act(() => {
eventHandler({
type: 'agent_end',
reason: 'completed',
id: '2',
timestamp: '',
streamId: '',
});
});
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should accumulate text content and update pendingHistoryItems', async () => {
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
addItem: mockAddItem,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
.calls[0][0];
act(() => {
eventHandler({
type: 'message',
role: 'agent',
content: [{ type: 'text', text: 'Hello' }],
id: '1',
timestamp: '',
streamId: '',
});
});
expect(result.current.pendingHistoryItems).toHaveLength(1);
expect(result.current.pendingHistoryItems[0]).toMatchObject({
type: 'gemini',
text: 'Hello',
});
act(() => {
eventHandler({
type: 'message',
role: 'agent',
content: [{ type: 'text', text: ' world' }],
id: '2',
timestamp: '',
streamId: '',
});
});
expect(result.current.pendingHistoryItems[0].text).toBe('Hello world');
});
it('should process thought events and update thought state', async () => {
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
addItem: mockAddItem,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
.calls[0][0];
act(() => {
eventHandler({
type: 'message',
role: 'agent',
content: [{ type: 'thought', thought: '**Thinking** about tests' }],
id: '1',
timestamp: '',
streamId: '',
});
});
expect(result.current.thought).toEqual({
subject: 'Thinking',
description: 'about tests',
});
});
it('should call agent.abort when cancelOngoingRequest is called', async () => {
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
addItem: mockAddItem,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
await act(async () => {
await result.current.cancelOngoingRequest();
});
expect(mockLegacyAgentProtocol.abort).toHaveBeenCalled();
expect(mockOnCancelSubmit).toHaveBeenCalledWith(false);
});
});
+528
View File
@@ -0,0 +1,528 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import {
getErrorMessage,
MessageSenderType,
debugLogger,
geminiPartsToContentParts,
parseThought,
CoreToolCallStatus,
type ApprovalMode,
Kind,
type ThoughtSummary,
type RetryAttemptPayload,
type AgentEvent,
type AgentProtocol,
type Logger,
type Part,
} from '@google/gemini-cli-core';
import type {
HistoryItemWithoutId,
LoopDetectionConfirmationRequest,
IndividualToolCallDisplay,
HistoryItemToolGroup,
} from '../types.js';
import { StreamingState, MessageType } from '../types.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
import { type BackgroundTask } from './useExecutionLifecycle.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useStateAndRef } from './useStateAndRef.js';
import { type MinimalTrackedToolCall } from './useTurnActivityMonitor.js';
export interface UseAgentStreamOptions {
agent?: AgentProtocol;
addItem: UseHistoryManagerReturn['addItem'];
onCancelSubmit: (shouldRestorePrompt?: boolean) => void;
isShellFocused?: boolean;
logger?: Logger | null;
}
/**
* useAgentStream implements the interactive agent loop using an AgentProtocol.
* It is completely agnostic to the specific agent implementation.
*/
export const useAgentStream = ({
agent,
addItem,
onCancelSubmit,
isShellFocused,
logger,
}: UseAgentStreamOptions) => {
const [initError] = useState<string | null>(null);
const [retryStatus] = useState<RetryAttemptPayload | null>(null);
const [streamingState, setStreamingState] = useState<StreamingState>(
StreamingState.Idle,
);
const [thought, setThought] = useState<ThoughtSummary | null>(null);
const [lastOutputTime, setLastOutputTime] = useState<number>(Date.now());
const currentStreamIdRef = useRef<string | null>(null);
const userMessageTimestampRef = useRef<number>(0);
const geminiMessageBufferRef = useRef<string>('');
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
const [trackedTools, , setTrackedTools] = useStateAndRef<
IndividualToolCallDisplay[]
>([]);
const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] =
useStateAndRef<Set<string>>(new Set());
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
useStateAndRef<boolean>(true);
const { startNewPrompt } = useSessionStats();
// TODO: Implement dynamic shell-related state derivation from trackedTools or dedicated refs.
// This includes activePtyId, backgroundTasks, and related visibility states to restore
// parity with legacy terminal focus detection and background task tracking.
// Note: Avoid checking ITERM_SESSION_ID for terminal detection and ensure context is sanitized.
const activePtyId = undefined;
const backgroundTaskCount = 0;
const isBackgroundTaskVisible = false;
const toggleBackgroundTasks = useCallback(() => {}, []);
const backgroundCurrentExecution = undefined;
const backgroundTasks = useMemo(() => new Map<number, BackgroundTask>(), []);
const dismissBackgroundTask = useCallback(async (_pid: number) => {}, []);
// Use the trackedTools to mock pendingToolCalls for inactivity monitors
const pendingToolCalls = useMemo(
(): MinimalTrackedToolCall[] =>
trackedTools.map((t) => ({
request: {
name: t.originalRequestName || t.name,
args: { command: t.description },
callId: t.callId,
isClientInitiated: t.isClientInitiated ?? false,
prompt_id: '',
},
status: t.status,
})),
[trackedTools],
);
// TODO: Support LoopDetection confirmation requests
const [loopDetectionConfirmationRequest] =
useState<LoopDetectionConfirmationRequest | null>(null);
const flushPendingText = useCallback(() => {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestampRef.current);
setPendingHistoryItem(null);
geminiMessageBufferRef.current = '';
}
}, [addItem, pendingHistoryItemRef, setPendingHistoryItem]);
const cancelOngoingRequest = useCallback(async () => {
if (agent) {
await agent.abort();
setStreamingState(StreamingState.Idle);
onCancelSubmit(false);
}
}, [agent, onCancelSubmit]);
// TODO: Support native handleApprovalModeChange for Plan Mode
const handleApprovalModeChange = useCallback(
async (newApprovalMode: ApprovalMode) => {
debugLogger.debug(`Approval mode changed to ${newApprovalMode} (stub)`);
},
[],
);
const handleEvent = useCallback(
(event: AgentEvent) => {
setLastOutputTime(Date.now());
switch (event.type) {
case 'agent_start':
setStreamingState(StreamingState.Responding);
break;
case 'agent_end':
setStreamingState(StreamingState.Idle);
flushPendingText();
break;
case 'message':
if (event.role === 'agent') {
for (const part of event.content) {
if (part.type === 'text') {
geminiMessageBufferRef.current += part.text;
// Update pending history item with incremental text
const splitPoint = findLastSafeSplitPoint(
geminiMessageBufferRef.current,
);
if (splitPoint === geminiMessageBufferRef.current.length) {
setPendingHistoryItem({
type: 'gemini',
text: geminiMessageBufferRef.current,
});
} else {
const before = geminiMessageBufferRef.current.substring(
0,
splitPoint,
);
const after =
geminiMessageBufferRef.current.substring(splitPoint);
addItem(
{ type: 'gemini', text: before },
userMessageTimestampRef.current,
);
geminiMessageBufferRef.current = after;
setPendingHistoryItem({
type: 'gemini_content',
text: after,
});
}
} else if (part.type === 'thought') {
setThought(parseThought(part.thought));
}
}
}
break;
case 'tool_request': {
flushPendingText();
const legacyState = event._meta?.legacyState;
const displayName = legacyState?.displayName ?? event.name;
const isOutputMarkdown = legacyState?.isOutputMarkdown ?? false;
const desc = legacyState?.description ?? '';
const fallbackKind = Kind.Other;
const newCall: IndividualToolCallDisplay = {
callId: event.requestId,
name: displayName,
originalRequestName: event.name,
description: desc,
status: CoreToolCallStatus.Scheduled,
isClientInitiated: false,
renderOutputAsMarkdown: isOutputMarkdown,
kind: legacyState?.kind ?? fallbackKind,
confirmationDetails: undefined,
resultDisplay: undefined,
};
setTrackedTools((prev) => [...prev, newCall]);
break;
}
case 'tool_update': {
setTrackedTools((prev) =>
prev.map((tc): IndividualToolCallDisplay => {
if (tc.callId !== event.requestId) return tc;
const legacyState = event._meta?.legacyState;
const evtStatus = legacyState?.status;
let status = tc.status;
if (evtStatus === 'executing')
status = CoreToolCallStatus.Executing;
else if (evtStatus === 'error') status = CoreToolCallStatus.Error;
else if (evtStatus === 'success')
status = CoreToolCallStatus.Success;
const liveOutput =
event.displayContent?.[0]?.type === 'text'
? event.displayContent[0].text
: tc.resultDisplay;
const progressMessage =
legacyState?.progressMessage ?? tc.progressMessage;
const progress = legacyState?.progress ?? tc.progress;
const progressTotal =
legacyState?.progressTotal ?? tc.progressTotal;
const ptyId = legacyState?.pid ?? tc.ptyId;
const description = legacyState?.description ?? tc.description;
return {
...tc,
status,
resultDisplay: liveOutput,
progressMessage,
progress,
progressTotal,
ptyId,
description,
};
}),
);
break;
}
case 'tool_response': {
setTrackedTools((prev) =>
prev.map((tc): IndividualToolCallDisplay => {
if (tc.callId !== event.requestId) return tc;
const legacyState = event._meta?.legacyState;
const outputFile = legacyState?.outputFile;
const resultDisplay =
event.displayContent?.[0]?.type === 'text'
? event.displayContent[0].text
: tc.resultDisplay;
return {
...tc,
status: event.isError
? CoreToolCallStatus.Error
: CoreToolCallStatus.Success,
resultDisplay,
outputFile,
};
}),
);
break;
}
case 'error':
addItem(
{ type: MessageType.ERROR, text: event.message },
userMessageTimestampRef.current,
);
break;
case 'initialize':
case 'session_update':
case 'elicitation_request':
case 'elicitation_response':
case 'usage':
case 'custom':
// These events are currently not handled in the UI
break;
default:
debugLogger.error('Unknown agent event type:', event);
event satisfies never;
break;
}
},
[
addItem,
flushPendingText,
setPendingHistoryItem,
setTrackedTools,
setStreamingState,
setThought,
setLastOutputTime,
],
);
useEffect(() => {
const unsubscribe = agent?.subscribe(handleEvent);
return () => unsubscribe?.();
}, [agent, handleEvent]);
const submitQuery = useCallback(
async (
query: Part[] | string,
options?: { isContinuation: boolean },
_prompt_id?: string,
) => {
if (!agent) return;
const timestamp = Date.now();
setLastOutputTime(timestamp);
userMessageTimestampRef.current = timestamp;
geminiMessageBufferRef.current = '';
if (!options?.isContinuation) {
if (typeof query === 'string') {
addItem({ type: MessageType.USER, text: query }, timestamp);
void logger?.logMessage(MessageSenderType.USER, query);
}
startNewPrompt();
}
const parts = geminiPartsToContentParts(
typeof query === 'string' ? [{ text: query }] : query,
);
try {
const { streamId } = await agent.send({
message: { content: parts },
});
currentStreamIdRef.current = streamId;
} catch (err) {
addItem(
{ type: MessageType.ERROR, text: getErrorMessage(err) },
timestamp,
);
}
},
[agent, addItem, logger, startNewPrompt],
);
useEffect(() => {
if (trackedTools.length > 0) {
const isNewBatch = !trackedTools.some((tc) =>
pushedToolCallIdsRef.current.has(tc.callId),
);
if (isNewBatch) {
setPushedToolCallIds(new Set());
setIsFirstToolInGroup(true);
}
} else if (streamingState === StreamingState.Idle) {
setPushedToolCallIds(new Set());
setIsFirstToolInGroup(true);
}
}, [
trackedTools,
pushedToolCallIdsRef,
setPushedToolCallIds,
setIsFirstToolInGroup,
streamingState,
]);
// Push completed tools to history
useEffect(() => {
const toolsToPush: IndividualToolCallDisplay[] = [];
for (let i = 0; i < trackedTools.length; i++) {
const tc = trackedTools[i];
if (pushedToolCallIdsRef.current.has(tc.callId)) continue;
if (
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled'
) {
toolsToPush.push(tc);
} else {
break;
}
}
if (toolsToPush.length > 0) {
const newPushed = new Set(pushedToolCallIdsRef.current);
for (const tc of toolsToPush) {
newPushed.add(tc.callId);
}
const isLastInBatch =
toolsToPush[toolsToPush.length - 1] ===
trackedTools[trackedTools.length - 1];
const appearance = getToolGroupBorderAppearance(
{ type: 'tool_group', tools: trackedTools },
activePtyId,
!!isShellFocused,
[],
backgroundTasks,
);
const historyItem: HistoryItemToolGroup = {
type: 'tool_group',
tools: toolsToPush,
borderTop: isFirstToolInGroupRef.current,
borderBottom: isLastInBatch,
...appearance,
};
addItem(historyItem);
setPushedToolCallIds(newPushed);
setIsFirstToolInGroup(false);
}
}, [
trackedTools,
pushedToolCallIdsRef,
isFirstToolInGroupRef,
setPushedToolCallIds,
setIsFirstToolInGroup,
addItem,
activePtyId,
isShellFocused,
backgroundTasks,
]);
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
const remainingTools = trackedTools.filter(
(tc) => !pushedToolCallIds.has(tc.callId),
);
const items: HistoryItemWithoutId[] = [];
const appearance = getToolGroupBorderAppearance(
{ type: 'tool_group', tools: trackedTools },
activePtyId,
!!isShellFocused,
[],
backgroundTasks,
);
if (remainingTools.length > 0) {
items.push({
type: 'tool_group',
tools: remainingTools,
borderTop: pushedToolCallIds.size === 0,
borderBottom: false,
...appearance,
});
}
const allTerminal =
trackedTools.length > 0 &&
trackedTools.every(
(tc) =>
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled',
);
const allPushed =
trackedTools.length > 0 &&
trackedTools.every((tc) => pushedToolCallIds.has(tc.callId));
const anyVisibleInHistory = pushedToolCallIds.size > 0;
const anyVisibleInPending = remainingTools.length > 0;
if (
trackedTools.length > 0 &&
!(allTerminal && allPushed) &&
(anyVisibleInHistory || anyVisibleInPending)
) {
items.push({
type: 'tool_group' as const,
tools: [],
borderTop: false,
borderBottom: true,
...appearance,
});
}
return items;
}, [
trackedTools,
pushedToolCallIds,
activePtyId,
isShellFocused,
backgroundTasks,
]);
const pendingHistoryItems = useMemo(
() =>
[pendingHistoryItem, ...pendingToolGroupItems].filter(
(i): i is HistoryItemWithoutId => i !== undefined && i !== null,
),
[pendingHistoryItem, pendingToolGroupItems],
);
return {
streamingState,
submitQuery,
initError,
pendingHistoryItems,
thought,
cancelOngoingRequest,
pendingToolCalls,
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
backgroundTasks,
retryStatus,
dismissBackgroundTask,
};
};
@@ -11,7 +11,6 @@ import {
useSessionBrowser,
convertSessionToHistoryFormats,
} from './useSessionBrowser.js';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { getSessionFiles, type SessionInfo } from '../../utils/sessionUtils.js';
import {
@@ -19,6 +18,7 @@ import {
type ConversationRecord,
type MessageRecord,
CoreToolCallStatus,
loadConversationRecord,
} from '@google/gemini-cli-core';
import {
coreEvents,
@@ -46,6 +46,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
clear: vi.fn(),
hydrate: vi.fn(),
},
loadConversationRecord: vi.fn(),
};
});
@@ -55,7 +56,6 @@ const MOCKED_SESSION_ID = 'test-session-123';
const MOCKED_CURRENT_SESSION_ID = 'current-session-id';
describe('useSessionBrowser', () => {
const mockedFs = vi.mocked(fs);
const mockedPath = vi.mocked(path);
const mockedGetSessionFiles = vi.mocked(getSessionFiles);
@@ -98,7 +98,7 @@ describe('useSessionBrowser', () => {
fileName: MOCKED_FILENAME,
} as SessionInfo;
mockedGetSessionFiles.mockResolvedValue([mockSession]);
mockedFs.readFile.mockResolvedValue(JSON.stringify(mockConversation));
vi.mocked(loadConversationRecord).mockResolvedValue(mockConversation);
const { result } = await renderHook(() =>
useSessionBrowser(mockConfig, mockOnLoadHistory),
@@ -107,9 +107,8 @@ describe('useSessionBrowser', () => {
await act(async () => {
await result.current.handleResumeSession(mockSession);
});
expect(mockedFs.readFile).toHaveBeenCalledWith(
expect(loadConversationRecord).toHaveBeenCalledWith(
`${MOCKED_CHATS_DIR}/${MOCKED_FILENAME}`,
'utf8',
);
expect(mockConfig.setSessionId).toHaveBeenCalledWith(
'existing-session-456',
@@ -125,7 +124,9 @@ describe('useSessionBrowser', () => {
id: MOCKED_SESSION_ID,
fileName: MOCKED_FILENAME,
} as SessionInfo;
mockedFs.readFile.mockRejectedValue(new Error('File not found'));
vi.mocked(loadConversationRecord).mockRejectedValue(
new Error('File not found'),
);
const { result } = await renderHook(() =>
useSessionBrowser(mockConfig, mockOnLoadHistory),
@@ -149,7 +150,7 @@ describe('useSessionBrowser', () => {
id: MOCKED_SESSION_ID,
fileName: MOCKED_FILENAME,
} as SessionInfo;
mockedFs.readFile.mockResolvedValue('invalid json');
vi.mocked(loadConversationRecord).mockResolvedValue(null);
const { result } = await renderHook(() =>
useSessionBrowser(mockConfig, mockOnLoadHistory),
@@ -6,14 +6,13 @@
import { useState, useCallback } from 'react';
import type { HistoryItemWithoutId } from '../types.js';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import {
coreEvents,
convertSessionToClientHistory,
uiTelemetryService,
loadConversationRecord,
type Config,
type ConversationRecord,
type ResumedSessionData,
} from '@google/gemini-cli-core';
import {
@@ -61,10 +60,12 @@ export const useSessionBrowser = (
const originalFilePath = path.join(chatsDir, fileName);
// Load up the conversation.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const conversation: ConversationRecord = JSON.parse(
await fs.readFile(originalFilePath, 'utf8'),
);
const conversation = await loadConversationRecord(originalFilePath);
if (!conversation) {
throw new Error(
`Failed to parse conversation from ${originalFilePath}`,
);
}
// Use the old session's ID to continue it.
const existingSessionId = conversation.sessionId;
@@ -5,20 +5,22 @@
*/
import { useInactivityTimer } from './useInactivityTimer.js';
import { useTurnActivityMonitor } from './useTurnActivityMonitor.js';
import {
useTurnActivityMonitor,
type MinimalTrackedToolCall,
} from './useTurnActivityMonitor.js';
import {
SHELL_FOCUS_HINT_DELAY_MS,
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
SHELL_SILENT_WORKING_TITLE_DELAY_MS,
} from '../constants.js';
import type { StreamingState } from '../types.js';
import { type TrackedToolCall } from './useToolScheduler.js';
interface ShellInactivityStatusProps {
activePtyId: number | string | null | undefined;
lastOutputTime: number;
streamingState: StreamingState;
pendingToolCalls: TrackedToolCall[];
pendingToolCalls: MinimalTrackedToolCall[];
embeddedShellFocused: boolean;
isInteractiveShellEnabled: boolean;
}
@@ -79,6 +79,7 @@ export function useToolScheduler(
React.Dispatch<React.SetStateAction<TrackedToolCall[]>>,
CancelAllFn,
number,
Scheduler,
] {
// State stores tool calls organized by their originating schedulerId
const [toolCallsMap, setToolCallsMap] = useState<
@@ -319,6 +320,7 @@ export function useToolScheduler(
setToolCallsForDisplay,
cancelAll,
lastToolOutputTime,
scheduler,
];
}
@@ -6,8 +6,16 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { StreamingState } from '../types.js';
import { hasRedirection } from '@google/gemini-cli-core';
import { type TrackedToolCall } from './useToolScheduler.js';
import {
hasRedirection,
type CoreToolCallStatus,
type ToolCallRequestInfo,
} from '@google/gemini-cli-core';
export interface MinimalTrackedToolCall {
status: CoreToolCallStatus;
request: ToolCallRequestInfo;
}
export interface TurnActivityStatus {
operationStartTime: number;
@@ -21,7 +29,7 @@ export interface TurnActivityStatus {
export const useTurnActivityMonitor = (
streamingState: StreamingState,
activePtyId: number | string | null | undefined,
pendingToolCalls: TrackedToolCall[] = [],
pendingToolCalls: MinimalTrackedToolCall[] = [],
): TurnActivityStatus => {
const [operationStartTime, setOperationStartTime] = useState(0);
+5 -2
View File
@@ -29,7 +29,10 @@ export function getToolGroupBorderAppearance(
item:
| HistoryItem
| HistoryItemWithoutId
| { type: 'tool_group'; tools: TrackedToolCall[] },
| {
type: 'tool_group';
tools: Array<IndividualToolCallDisplay | TrackedToolCall>;
},
activeShellPtyId: number | null | undefined,
embeddedShellFocused: boolean | undefined,
allPendingItems: HistoryItemWithoutId[] = [],
@@ -41,7 +44,7 @@ export function getToolGroupBorderAppearance(
// If this item has no tools, it's a closing slice for the current batch.
// We need to look at the last pending item to determine the batch's appearance.
const toolsToInspect: Array<IndividualToolCallDisplay | TrackedToolCall> =
const toolsToInspect =
item.tools.length > 0
? item.tools
: allPendingItems
+22 -15
View File
@@ -12,6 +12,7 @@ import {
type Storage,
type ConversationRecord,
type MessageRecord,
loadConversationRecord,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import path from 'node:path';
@@ -250,23 +251,27 @@ export const getAllSessionFiles = async (
try {
const files = await fs.readdir(chatsDir);
const sessionFiles = files
.filter((f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json'))
.filter(
(f) =>
f.startsWith(SESSION_FILE_PREFIX) &&
(f.endsWith('.json') || f.endsWith('.jsonl')),
)
.sort(); // Sort by filename, which includes timestamp
const sessionPromises = sessionFiles.map(
async (file): Promise<SessionFileEntry> => {
const filePath = path.join(chatsDir, file);
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const content: ConversationRecord = JSON.parse(
await fs.readFile(filePath, 'utf8'),
);
const content = await loadConversationRecord(filePath, {
metadataOnly: !options.includeFullContent,
});
if (!content) {
return { fileName: file, sessionInfo: null };
}
// Validate required fields
if (
!content.sessionId ||
!content.messages ||
!Array.isArray(content.messages) ||
!content.startTime ||
!content.lastUpdated
) {
@@ -275,7 +280,7 @@ export const getAllSessionFiles = async (
}
// Skip sessions that only contain system messages (info, error, warning)
if (!hasUserOrAssistantMessage(content.messages)) {
if (!content.hasUserOrAssistantMessage) {
return { fileName: file, sessionInfo: null };
}
@@ -285,7 +290,9 @@ export const getAllSessionFiles = async (
return { fileName: file, sessionInfo: null };
}
const firstUserMessage = extractFirstUserMessage(content.messages);
const firstUserMessage = content.firstUserMessage
? cleanMessage(content.firstUserMessage)
: extractFirstUserMessage(content.messages);
const isCurrentSession = currentSessionId
? file.includes(currentSessionId.slice(0, 8))
: false;
@@ -310,11 +317,11 @@ export const getAllSessionFiles = async (
const sessionInfo: SessionInfo = {
id: content.sessionId,
file: file.replace('.json', ''),
file: file.replace(/\.jsonl?$/, ''),
fileName: file,
startTime: content.startTime,
lastUpdated: content.lastUpdated,
messageCount: content.messages.length,
messageCount: content.messageCount ?? content.messages.length,
displayName: content.summary
? stripUnsafeCharacters(content.summary)
: firstUserMessage,
@@ -505,10 +512,10 @@ export class SessionSelector {
const sessionPath = path.join(chatsDir, sessionInfo.fileName);
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const sessionData: ConversationRecord = JSON.parse(
await fs.readFile(sessionPath, 'utf8'),
);
const sessionData = await loadConversationRecord(sessionPath);
if (!sessionData) {
throw new Error('Failed to load session data');
}
const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`;