mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-06 00:52:45 -07:00
feat(cli): add useBtw hook and slash command processing
This commit is contained in:
committed by
Mahima Shanware
parent
09774da43c
commit
4bc7e2554f
@@ -12,16 +12,14 @@ export const btwCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: (context, args) => {
|
||||
action: (_context, args) => {
|
||||
const query = args.trim();
|
||||
if (!query) {
|
||||
return {
|
||||
type: 'command_output',
|
||||
value: [
|
||||
{
|
||||
text: 'Please provide a question, e.g. /btw what is this regex doing?',
|
||||
},
|
||||
],
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'Please provide a question, e.g. /btw what is this regex doing?',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
HistoryItemWithoutId,
|
||||
HistoryItem,
|
||||
ConfirmationRequest,
|
||||
BtwActionReturn,
|
||||
} from '../types.js';
|
||||
import type {
|
||||
GitService,
|
||||
@@ -166,11 +167,6 @@ export interface LogoutActionReturn {
|
||||
type: 'logout';
|
||||
}
|
||||
|
||||
export interface BtwActionReturn {
|
||||
type: 'btw';
|
||||
query: string;
|
||||
}
|
||||
|
||||
export type SlashCommandActionReturn =
|
||||
| CommandActionReturn<HistoryItemWithoutId[]>
|
||||
| QuitActionReturn
|
||||
|
||||
@@ -662,6 +662,9 @@ export const useSlashCommandProcessor = (
|
||||
setCustomDialog(result.component);
|
||||
return { type: 'handled' };
|
||||
}
|
||||
case 'btw': {
|
||||
return result;
|
||||
}
|
||||
default: {
|
||||
const unhandled: never = result;
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { useBtw } from './useBtw.js';
|
||||
import { type GeminiClient, GeminiEventType } from '@google/gemini-cli-core';
|
||||
|
||||
describe('useBtw', () => {
|
||||
let mockGeminiClient: {
|
||||
sendBtwStream: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGeminiClient = {
|
||||
sendBtwStream: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should initialize with inactive state', async () => {
|
||||
const { result } = await renderHook(() =>
|
||||
useBtw(mockGeminiClient as unknown as GeminiClient),
|
||||
);
|
||||
expect(result.current.isActive).toBe(false);
|
||||
expect(result.current.query).toBe('');
|
||||
expect(result.current.response).toBe('');
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('should update state during streaming', async () => {
|
||||
let resolveStream: (value: void) => void;
|
||||
const streamGate = new Promise<void>((resolve) => {
|
||||
resolveStream = resolve;
|
||||
});
|
||||
|
||||
const mockStream = (async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Hello' };
|
||||
await streamGate;
|
||||
yield { type: GeminiEventType.Content, value: ' world' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
})();
|
||||
mockGeminiClient.sendBtwStream.mockReturnValue(mockStream);
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
useBtw(mockGeminiClient as unknown as GeminiClient),
|
||||
);
|
||||
|
||||
let submitPromise!: Promise<void>;
|
||||
await act(async () => {
|
||||
submitPromise = result.current.submitBtw('test query');
|
||||
});
|
||||
|
||||
// Check immediate state (it should be streaming because of streamGate)
|
||||
expect(result.current.isActive).toBe(true);
|
||||
expect(result.current.query).toBe('test query');
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.response).toBe('Hello');
|
||||
|
||||
await act(async () => {
|
||||
resolveStream();
|
||||
await submitPromise;
|
||||
});
|
||||
|
||||
// Check final state
|
||||
expect(result.current.response).toBe('Hello world');
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
const mockStream = (async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.Error,
|
||||
value: { error: { message: 'API Error' } },
|
||||
};
|
||||
})();
|
||||
mockGeminiClient.sendBtwStream.mockReturnValue(mockStream);
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
useBtw(mockGeminiClient as unknown as GeminiClient),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitBtw('test query');
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('API Error');
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset state on dismiss', async () => {
|
||||
const mockStream = (async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'partial' };
|
||||
// Hang
|
||||
await new Promise(() => {});
|
||||
})();
|
||||
mockGeminiClient.sendBtwStream.mockReturnValue(mockStream);
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
useBtw(mockGeminiClient as unknown as GeminiClient),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
void result.current.submitBtw('test query');
|
||||
});
|
||||
|
||||
expect(result.current.isActive).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.dismissBtw();
|
||||
});
|
||||
|
||||
expect(result.current.isActive).toBe(false);
|
||||
expect(result.current.query).toBe('');
|
||||
expect(result.current.response).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect, useReducer } from 'react';
|
||||
import { type GeminiClient, GeminiEventType } from '@google/gemini-cli-core';
|
||||
|
||||
export interface UseBtwReturn {
|
||||
isActive: boolean;
|
||||
query: string;
|
||||
response: string;
|
||||
isStreaming: boolean;
|
||||
error: string | null;
|
||||
submitBtw: (query: string) => Promise<void>;
|
||||
dismissBtw: () => void;
|
||||
}
|
||||
|
||||
interface BtwState {
|
||||
isActive: boolean;
|
||||
query: string;
|
||||
response: string;
|
||||
isStreaming: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type BtwAction =
|
||||
| { type: 'SUBMIT'; query: string }
|
||||
| { type: 'APPEND_CONTENT'; content: string }
|
||||
| { type: 'ERROR'; error: string }
|
||||
| { type: 'FINISHED' }
|
||||
| { type: 'DISMISS' };
|
||||
|
||||
const initialState: BtwState = {
|
||||
isActive: false,
|
||||
query: '',
|
||||
response: '',
|
||||
isStreaming: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const btwReducer = (state: BtwState, action: BtwAction): BtwState => {
|
||||
switch (action.type) {
|
||||
case 'SUBMIT':
|
||||
return {
|
||||
...state,
|
||||
isActive: true,
|
||||
query: action.query,
|
||||
response: '',
|
||||
isStreaming: true,
|
||||
error: null,
|
||||
};
|
||||
case 'APPEND_CONTENT':
|
||||
return {
|
||||
...state,
|
||||
response: state.response + action.content,
|
||||
};
|
||||
case 'ERROR':
|
||||
return {
|
||||
...state,
|
||||
error: action.error,
|
||||
isStreaming: false,
|
||||
};
|
||||
case 'FINISHED':
|
||||
return {
|
||||
...state,
|
||||
isStreaming: false,
|
||||
};
|
||||
case 'DISMISS':
|
||||
return initialState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const useBtw = (
|
||||
geminiClient: GeminiClient | undefined,
|
||||
): UseBtwReturn => {
|
||||
const [state, dispatch] = useReducer(btwReducer, initialState);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const dismissBtw = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
dispatch({ type: 'DISMISS' });
|
||||
}, []);
|
||||
|
||||
const submitBtw = useCallback(
|
||||
async (newQuery: string) => {
|
||||
if (!geminiClient) return;
|
||||
|
||||
// Abort any ongoing BTW stream
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
dispatch({ type: 'SUBMIT', query: newQuery });
|
||||
|
||||
try {
|
||||
const stream = geminiClient.sendBtwStream(
|
||||
[{ text: newQuery }],
|
||||
abortController.signal,
|
||||
`btw-${Date.now()}`,
|
||||
);
|
||||
|
||||
for await (const event of stream) {
|
||||
if (abortController.signal.aborted) break;
|
||||
|
||||
switch (event.type) {
|
||||
case GeminiEventType.Content:
|
||||
dispatch({ type: 'APPEND_CONTENT', content: event.value ?? '' });
|
||||
break;
|
||||
case GeminiEventType.Error: {
|
||||
const value = event.value;
|
||||
let errorMessage = 'Unknown error';
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'error' in value &&
|
||||
typeof value.error === 'object' &&
|
||||
value.error !== null
|
||||
) {
|
||||
const errorObj = value.error;
|
||||
errorMessage =
|
||||
'message' in errorObj
|
||||
? String(errorObj.message)
|
||||
: String(errorObj);
|
||||
} else {
|
||||
errorMessage = String(value);
|
||||
}
|
||||
dispatch({
|
||||
type: 'ERROR',
|
||||
error: errorMessage,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case GeminiEventType.Finished:
|
||||
dispatch({ type: 'FINISHED' });
|
||||
break;
|
||||
case GeminiEventType.UserCancelled:
|
||||
dispatch({ type: 'FINISHED' });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
// Ignore aborts
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'ERROR',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (abortControllerRef.current === abortController) {
|
||||
dispatch({ type: 'FINISHED' });
|
||||
}
|
||||
}
|
||||
},
|
||||
[geminiClient],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
submitBtw,
|
||||
dismissBtw,
|
||||
};
|
||||
};
|
||||
@@ -990,6 +990,9 @@ export const useGeminiStream = (
|
||||
case 'handled': {
|
||||
return { queryToSend: null, shouldProceed: false };
|
||||
}
|
||||
case 'btw': {
|
||||
return { queryToSend: null, shouldProceed: false };
|
||||
}
|
||||
default: {
|
||||
const unreachable: never = slashCommandResult;
|
||||
throw new Error(
|
||||
|
||||
@@ -500,6 +500,11 @@ export interface SubmitPromptResult {
|
||||
content: PartListUnion;
|
||||
}
|
||||
|
||||
export interface BtwActionReturn {
|
||||
type: 'btw';
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the result of the slash command processor for its consumer (useGeminiStream).
|
||||
*/
|
||||
@@ -513,7 +518,8 @@ export type SlashCommandProcessorResult =
|
||||
| {
|
||||
type: 'handled'; // Indicates the command was processed and no further action is needed.
|
||||
}
|
||||
| SubmitPromptResult;
|
||||
| SubmitPromptResult
|
||||
| BtwActionReturn;
|
||||
|
||||
export interface ConfirmationRequest {
|
||||
prompt: ReactNode;
|
||||
|
||||
Reference in New Issue
Block a user