diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 466e70c994..02a271d6d5 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -175,6 +175,13 @@ export type SlashCommandActionReturn = | OpenCustomDialogActionReturn | LogoutActionReturn; +export enum CommandSource { + CORE = 'core', + EXTENSION = 'extension', + USER = 'user', + WORKSPACE = 'workspace', +} + export enum CommandKind { BUILT_IN = 'built-in', USER_FILE = 'user-file', diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx index 171e29e905..108092b488 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx @@ -12,6 +12,7 @@ import { Command } from '../key/keyMatchers.js'; import { formatCommand } from '../key/keybindingUtils.js'; interface ContextSummaryDisplayProps { + activeExtensionName?: string; geminiMdFileCount: number; contextFileNames: string[]; mcpServers?: Record; @@ -29,6 +30,7 @@ export const ContextSummaryDisplay: React.FC = ({ ideContext, skillCount, backgroundProcessCount = 0, + activeExtensionName, }) => { const mcpServerCount = Object.keys(mcpServers || {}).length; const blockedMcpServerCount = blockedMcpServers?.length || 0; @@ -40,7 +42,8 @@ export const ContextSummaryDisplay: React.FC = ({ blockedMcpServerCount === 0 && openFileCount === 0 && skillCount === 0 && - backgroundProcessCount === 0 + backgroundProcessCount === 0 && + !activeExtensionName ) { return null; } @@ -103,12 +106,20 @@ export const ContextSummaryDisplay: React.FC = ({ }`; })(); + const extensionText = (() => { + if (!activeExtensionName) { + return ''; + } + return `Extension: ${activeExtensionName}`; + })(); + const summaryParts = [ openFilesText, geminiMdText, mcpText, skillText, backgroundText, + extensionText, ].filter(Boolean); return ( diff --git a/packages/cli/src/ui/components/StatusDisplay.tsx b/packages/cli/src/ui/components/StatusDisplay.tsx index 7cd0656e60..18f4233823 100644 --- a/packages/cli/src/ui/components/StatusDisplay.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.tsx @@ -39,6 +39,7 @@ export const StatusDisplay: React.FC = ({ } skillCount={config.getSkillManager().getDisplayableSkills().length} backgroundProcessCount={uiState.backgroundTaskCount} + activeExtensionName={config.activeExtensionName} /> ); } diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 2dba4a2a6c..0128474b8f 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -47,7 +47,12 @@ import type { } from '../types.js'; import { MessageType } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; -import { type CommandContext, type SlashCommand } from '../commands/types.js'; +import { + type CommandContext, + type SlashCommand, + CommandSource, + CommandKind, +} from '../commands/types.js'; import { CommandService } from '../../services/CommandService.js'; import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; import { FileCommandLoader } from '../../services/FileCommandLoader.js'; @@ -92,6 +97,43 @@ interface SlashCommandProcessorActions { /** * Hook to define and process slash commands (e.g., /help, /clear). */ + +function getCommandSource(command: SlashCommand): CommandSource { + if ( + command.extensionName || + command.kind === CommandKind.EXTENSION_FILE || + command.kind === CommandKind.MCP_PROMPT || + command.kind === CommandKind.SKILL + ) + return CommandSource.EXTENSION; + if (command.kind === CommandKind.WORKSPACE_FILE) + return CommandSource.WORKSPACE; + if (command.kind === CommandKind.USER_FILE) return CommandSource.USER; + return CommandSource.CORE; +} + +function shouldClearExtensionMRU(command: SlashCommand | undefined): boolean { + if (!command) return false; + + // Explicitly reset commands + if (command.name === 'clear' || command.name === 'extension reset') + return true; + + // Exempt informational commands + const exemptions = [ + 'help', + 'settings', + 'status', + 'history', + 'bug', + 'exit', + 'quit', + ]; + if (exemptions.includes(command.name)) return false; + + return getCommandSource(command) === CommandSource.CORE; +} + export const useSlashCommandProcessor = ( config: Config | null, settings: LoadedSettings, @@ -449,7 +491,8 @@ export const useSlashCommandProcessor = ( toolArgs: result.toolArgs, postSubmitPrompt: result.postSubmitPrompt, activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; case 'message': addItem( @@ -465,7 +508,8 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; case 'logout': // Show logout confirmation dialog with Login/Exit options @@ -485,7 +529,8 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; case 'dialog': switch (result.dialog) { @@ -494,49 +539,56 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; case 'theme': actions.openThemeDialog(); return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; case 'editor': actions.openEditorDialog(); return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; case 'privacy': actions.openPrivacyNotice(); return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; case 'sessionBrowser': actions.openSessionBrowser(); return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; case 'settings': actions.openSettingsDialog(); return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; case 'model': actions.openModelDialog(); return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; case 'agentConfig': { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -563,7 +615,8 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; } case 'permissions': @@ -574,13 +627,15 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; case 'help': return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; default: { const unhandled: never = result.dialog; @@ -598,7 +653,8 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; } case 'quit': @@ -606,7 +662,8 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; case 'submit_prompt': @@ -614,7 +671,8 @@ export const useSlashCommandProcessor = ( type: 'submit_prompt', content: result.content, activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; case 'confirm_shell_commands': { const callId = `expansion-${Date.now()}`; @@ -674,7 +732,8 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; } @@ -716,7 +775,8 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; } @@ -731,7 +791,8 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: + shouldClearExtensionMRU(commandToExecute), }; } default: { @@ -746,7 +807,7 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: shouldClearExtensionMRU(commandToExecute), }; } else if (commandToExecute.subCommands) { const helpText = `Command '/${commandToExecute.name}' requires a subcommand. Available:\n${commandToExecute.subCommands @@ -760,7 +821,7 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: shouldClearExtensionMRU(commandToExecute), }; } } @@ -768,7 +829,7 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: shouldClearExtensionMRU(commandToExecute), }; } catch (e: unknown) { hasError = true; @@ -791,7 +852,7 @@ export const useSlashCommandProcessor = ( return { type: 'handled', activeExtensionName: commandToExecute?.extensionName, - clearExtensionMRU: commandToExecute?.name === 'clear', + clearExtensionMRU: shouldClearExtensionMRU(commandToExecute), }; } finally { if (config && resolvedCommandPath[0] && !hasError) { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index d6c68ec880..0e35a69513 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -100,6 +100,7 @@ const MockedGeminiClientClass = vi.hoisted(() => recordMessageTokens: vi.fn(), recordToolCalls: vi.fn(), getConversationFile: vi.fn(), + recordActiveExtensionName: vi.fn(), }); this.getCurrentSequenceModel = vi .fn() @@ -310,6 +311,8 @@ describe('useGeminiStream', () => { debugMode: false, question: undefined, coreTools: [], + activeExtensionName: undefined, + setActiveExtensionName: vi.fn(), toolDiscoveryCommand: undefined, toolCallCommand: undefined, mcpServerCommand: undefined, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index a2e65b8fca..55ce956694 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -937,10 +937,20 @@ export const useGeminiStream = ( if (slashCommandResult) { if (slashCommandResult.clearExtensionMRU) { config.setActiveExtensionName(undefined); + config + .getGeminiClient() + ?.getChatRecordingService() + ?.recordActiveExtensionName(undefined); } else if (slashCommandResult.activeExtensionName) { config.setActiveExtensionName( slashCommandResult.activeExtensionName, ); + config + .getGeminiClient() + ?.getChatRecordingService() + ?.recordActiveExtensionName( + slashCommandResult.activeExtensionName, + ); } switch (slashCommandResult.type) { case 'schedule_tool': { @@ -1762,6 +1772,10 @@ export const useGeminiStream = ( newApprovalMode !== ApprovalMode.PLAN ) { config.setActiveExtensionName(undefined); + config + .getGeminiClient() + ?.getChatRecordingService() + ?.recordActiveExtensionName(undefined); } if ( previousApprovalModeRef.current === ApprovalMode.PLAN && diff --git a/packages/cli/src/ui/hooks/useSessionResume.ts b/packages/cli/src/ui/hooks/useSessionResume.ts index 055686773b..97b774d7fa 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.ts @@ -83,6 +83,13 @@ export function useSessionResume({ workspaceContext.addDirectories(resumedData.conversation.directories); } + // Restore active extension context + if (resumedData.conversation.activeExtensionName) { + config.setActiveExtensionName( + resumedData.conversation.activeExtensionName, + ); + } + // Give the history to the Gemini client. await config.getGeminiClient()?.resumeChat(clientHistory, resumedData); } catch (error) { diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index cab67f80a1..da93d0d9d4 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -603,6 +603,18 @@ export class ChatRecordingService { } } + recordActiveExtensionName(activeExtensionName: string | undefined): void { + if (!this.conversationFile) return; + try { + this.updateMetadata({ activeExtensionName }); + } catch (error) { + debugLogger.error( + 'Error saving active extension to chat history.', + error, + ); + } + } + getConversation(): ConversationRecord | null { if (!this.conversationFile) return null; return this.cachedConversation; diff --git a/packages/core/src/services/chatRecordingTypes.ts b/packages/core/src/services/chatRecordingTypes.ts index 2ddc218bdc..da370f75c3 100644 --- a/packages/core/src/services/chatRecordingTypes.ts +++ b/packages/core/src/services/chatRecordingTypes.ts @@ -87,6 +87,8 @@ export interface ConversationRecord { directories?: string[]; /** The kind of conversation (main agent or subagent) */ kind?: 'main' | 'subagent'; + /** The last active extension context for the session */ + activeExtensionName?: string; } /**