From 04f05459f8b7402371294cd07d85b06f503b2456 Mon Sep 17 00:00:00 2001 From: Akhilesh Kumar Date: Fri, 10 Apr 2026 17:47:27 +0000 Subject: [PATCH] feat: add /enhance command to improve user prompts This adds the /enhance command which uses an LLM to refine user prompts based on conversation history. Closes #25133 --- .gemini/settings.json | 11 +- .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../cli/src/test-utils/mockCommandContext.ts | 1 + .../src/ui/commands/enhanceCommand.test.ts | 175 ++++++++++++++++++ .../cli/src/ui/commands/enhanceCommand.ts | 104 +++++++++++ packages/cli/src/ui/commands/types.ts | 6 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 1 + .../src/ui/noninteractive/nonInteractiveUi.ts | 1 + 8 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/ui/commands/enhanceCommand.test.ts create mode 100644 packages/cli/src/ui/commands/enhanceCommand.ts diff --git a/.gemini/settings.json b/.gemini/settings.json index 9051dc78de..b1bcf87e72 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -1,11 +1,16 @@ { "experimental": { - "plan": true, "extensionReloading": true, "modelSteering": true, "memoryManager": true }, "general": { - "devtools": true + "devtools": true, + "plan": { + "enabled": true + } + }, + "agents": { + "overrides": {} } -} +} \ No newline at end of file diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 66806f5ef1..30581f802a 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -31,6 +31,7 @@ import { corgiCommand } from '../ui/commands/corgiCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; +import { enhanceCommand } from '../ui/commands/enhanceCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { footerCommand } from '../ui/commands/footerCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; @@ -133,6 +134,7 @@ export class BuiltinCommandLoader implements ICommandLoader { docsCommand, directoryCommand, editorCommand, + enhanceCommand, ...(this.config?.getExtensionsEnabled() === false ? [ { diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 6eda7f3109..63148b9d83 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -58,6 +58,7 @@ export const createMockCommandContext = ( pendingItem: null, setPendingItem: vi.fn(), loadHistory: vi.fn(), + setInput: vi.fn(), toggleCorgiMode: vi.fn(), toggleShortcutsHelp: vi.fn(), toggleVimEnabled: vi.fn(), diff --git a/packages/cli/src/ui/commands/enhanceCommand.test.ts b/packages/cli/src/ui/commands/enhanceCommand.test.ts new file mode 100644 index 0000000000..d3d5fc502a --- /dev/null +++ b/packages/cli/src/ui/commands/enhanceCommand.test.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { enhanceCommand } from './enhanceCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { MessageType } from '../types.js'; +import { LlmRole } from '@google/gemini-cli-core'; + +describe('enhanceCommand', () => { + let mockContext: CommandContext; + let mockGenerateContent: Mock; + + beforeEach(() => { + mockGenerateContent = vi.fn(); + mockContext = createMockCommandContext({ + services: { + agentContext: { + promptId: 'test-prompt-id', + geminiClient: { + getHistory: vi.fn().mockReturnValue([ + { role: 'user', parts: [{ text: 'previous user msg' }] }, + { role: 'model', parts: [{ text: 'previous model msg' }] }, + ]), + }, + config: { + getModel: vi.fn().mockReturnValue('test-model'), + getContentGenerator: vi.fn().mockReturnValue({ + generateContent: mockGenerateContent, + }), + getGemini31LaunchedSync: vi.fn().mockReturnValue(true), + getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), + }, + }, + }, + ui: { + addItem: vi.fn(), + setDebugMessage: vi.fn(), + }, + } as unknown as CommandContext); + }); + + it('should have the correct name and description', () => { + expect(enhanceCommand.name).toBe('enhance'); + expect(enhanceCommand.description).toBe( + 'Enhance a prompt with additional context and rephrasing', + ); + }); + + it('should show error if no prompt is provided', async () => { + if (!enhanceCommand.action) throw new Error('Action must be defined'); + + await enhanceCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: expect.stringContaining('Please provide a prompt'), + }), + ); + }); + + it('should call generateContent with correct parameters and show enhanced prompt', async () => { + if (!enhanceCommand.action) throw new Error('Action must be defined'); + + mockGenerateContent.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Enhanced: do something' }], + }, + }, + ], + }); + + await enhanceCommand.action(mockContext, 'do something'); + + expect(mockGenerateContent).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'test-model', + contents: [ + { role: 'user', parts: [{ text: 'previous user msg' }] }, + { role: 'model', parts: [{ text: 'previous model msg' }] }, + { role: 'user', parts: [{ text: 'do something' }] }, + ], + config: { + systemInstruction: { + role: 'system', + parts: [ + { + text: expect.stringContaining( + "Generate an enhanced version of the user's prompt", + ), + }, + ], + }, + }, + }), + 'test-prompt-id', + LlmRole.UTILITY_TOOL, + ); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining( + 'Enhanced prompt:\n\nEnhanced: do something', + ), + }), + ); + expect(mockContext.ui.setInput).toHaveBeenCalledWith( + 'Enhanced: do something', + ); + }); + + it('should clean the response from markdown and quotes', async () => { + if (!enhanceCommand.action) throw new Error('Action must be defined'); + + mockGenerateContent.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: '```markdown\n"Clean me"\n```' }], + }, + }, + ], + }); + + await enhanceCommand.action(mockContext, 'dirty prompt'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('Enhanced prompt:\n\nClean me'), + }), + ); + expect(mockContext.ui.setInput).toHaveBeenCalledWith('Clean me'); + }); + + it('should handle API errors gracefully', async () => { + if (!enhanceCommand.action) throw new Error('Action must be defined'); + + mockGenerateContent.mockRejectedValue(new Error('API Error')); + + await enhanceCommand.action(mockContext, 'test prompt'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: expect.stringContaining('Failed to enhance prompt: API Error'), + }), + ); + }); + + it('should handle empty response from model', async () => { + if (!enhanceCommand.action) throw new Error('Action must be defined'); + + mockGenerateContent.mockResolvedValue({ + candidates: [], + }); + + await enhanceCommand.action(mockContext, 'test prompt'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: expect.stringContaining('Empty response from model'), + }), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/enhanceCommand.ts b/packages/cli/src/ui/commands/enhanceCommand.ts new file mode 100644 index 0000000000..f76ffa24f0 --- /dev/null +++ b/packages/cli/src/ui/commands/enhanceCommand.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from './types.js'; +import { MessageType } from '../types.js'; +import { LlmRole, resolveModel } from '@google/gemini-cli-core'; +import type { Content } from '@google/genai'; + +const INSTRUCTION = + "Generate an enhanced version of the user's prompt, using the preceding conversation as context. Reply with ONLY the enhanced prompt - no conversation, explanations, lead-in, bullet points, placeholders, or surrounding quotes."; + +function clean(text: string) { + const stripped = text.replace(/^```\w*\n?|```$/g, '').trim(); + return stripped.replace(/^(['"])([\s\S]*)\1$/, '$2').trim(); +} + +export const enhanceCommand: SlashCommand = { + name: 'enhance', + description: 'Enhance a prompt with additional context and rephrasing', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context, args) => { + const draft = args.trim(); + if (!draft) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Please provide a prompt to enhance. Usage: /enhance ', + }); + return; + } + + const agentContext = context.services.agentContext; + if (!agentContext) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Agent context not available.', + }); + return; + } + + const config = agentContext.config; + const contentGenerator = config.getContentGenerator(); + const promptId = agentContext.promptId; + + const model = resolveModel( + config.getModel(), + config.getGemini31LaunchedSync?.() ?? false, + false, + config.getHasAccessToPreviewModel?.() ?? true, + config, + ); + + context.ui.setDebugMessage('Enhancing prompt...'); + + try { + const history = agentContext.geminiClient?.getHistory() ?? []; + const contents: Content[] = [ + ...history, + { role: 'user', parts: [{ text: draft }] }, + ]; + + const response = await contentGenerator.generateContent( + { + model, + contents, + config: { + systemInstruction: { + role: 'system', + parts: [{ text: INSTRUCTION }], + }, + }, + }, + promptId, + LlmRole.UTILITY_TOOL, + ); + + const enhancedText = response.candidates?.[0]?.content?.parts?.[0]?.text; + + if (enhancedText) { + const cleanedText = clean(enhancedText); + context.ui.addItem({ + type: MessageType.INFO, + text: `Enhanced prompt:\n\n${cleanedText}`, + }); + context.ui.setInput(cleanedText); + } else { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Failed to enhance prompt: Empty response from model.', + }); + } + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to enhance prompt: ${error instanceof Error ? error.message : String(error)}`, + }); + } finally { + context.ui.setDebugMessage(''); + } + }, +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 4065e075bf..d017638677 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -70,6 +70,12 @@ export interface CommandContext { * @param postLoadInput Optional text to set in the input buffer after loading history. */ loadHistory: (history: HistoryItem[], postLoadInput?: string) => void; + /** + * Sets the text in the input buffer. + * + * @param text The text to set. + */ + setInput: (text: string) => void; /** Toggles a special display mode. */ toggleCorgiMode: () => void; toggleDebugProfiler: () => void; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 1839670df7..ab2d0444a8 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -228,6 +228,7 @@ export const useSlashCommandProcessor = ( actions.setText(postLoadInput); } }, + setInput: (text) => actions.setText(text), setDebugMessage: actions.setDebugMessage, pendingItem, setPendingItem, diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index 00efd3f7fc..715cd661d5 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -29,6 +29,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] { clear: () => {}, setDebugMessage: (_message) => {}, loadHistory: (_newHistory) => {}, + setInput: (_text) => {}, pendingItem: null, setPendingItem: (_item) => {}, toggleCorgiMode: () => {},