|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { useCallback, useMemo } from 'react'; |
|
|
import { type PartListUnion } from '@google/genai'; |
|
|
import open from 'open'; |
|
|
import process from 'node:process'; |
|
|
import { UseHistoryManagerReturn } from './useHistoryManager.js'; |
|
|
import { useStateAndRef } from './useStateAndRef.js'; |
|
|
import { |
|
|
Config, |
|
|
GitService, |
|
|
Logger, |
|
|
MCPDiscoveryState, |
|
|
MCPServerStatus, |
|
|
getMCPDiscoveryState, |
|
|
getMCPServerStatus, |
|
|
} from '@google/gemini-cli-core'; |
|
|
import { useSessionStats } from '../contexts/SessionContext.js'; |
|
|
import { |
|
|
Message, |
|
|
MessageType, |
|
|
HistoryItemWithoutId, |
|
|
HistoryItem, |
|
|
} from '../types.js'; |
|
|
import { promises as fs } from 'fs'; |
|
|
import path from 'path'; |
|
|
import { createShowMemoryAction } from './useShowMemoryCommand.js'; |
|
|
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; |
|
|
import { formatDuration, formatMemoryUsage } from '../utils/formatters.js'; |
|
|
import { getCliVersion } from '../../utils/version.js'; |
|
|
import { LoadedSettings } from '../../config/settings.js'; |
|
|
|
|
|
export interface SlashCommandActionReturn { |
|
|
shouldScheduleTool?: boolean; |
|
|
toolName?: string; |
|
|
toolArgs?: Record<string, unknown>; |
|
|
message?: string; |
|
|
} |
|
|
|
|
|
export interface SlashCommand { |
|
|
name: string; |
|
|
altName?: string; |
|
|
description?: string; |
|
|
completion?: () => Promise<string[]>; |
|
|
action: ( |
|
|
mainCommand: string, |
|
|
subCommand?: string, |
|
|
args?: string, |
|
|
) => |
|
|
| void |
|
|
| SlashCommandActionReturn |
|
|
| Promise<void | SlashCommandActionReturn>; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const useSlashCommandProcessor = ( |
|
|
config: Config | null, |
|
|
settings: LoadedSettings, |
|
|
history: HistoryItem[], |
|
|
addItem: UseHistoryManagerReturn['addItem'], |
|
|
clearItems: UseHistoryManagerReturn['clearItems'], |
|
|
loadHistory: UseHistoryManagerReturn['loadHistory'], |
|
|
refreshStatic: () => void, |
|
|
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>, |
|
|
onDebugMessage: (message: string) => void, |
|
|
openThemeDialog: () => void, |
|
|
openAuthDialog: () => void, |
|
|
openEditorDialog: () => void, |
|
|
performMemoryRefresh: () => Promise<void>, |
|
|
toggleCorgiMode: () => void, |
|
|
showToolDescriptions: boolean = false, |
|
|
setQuittingMessages: (message: HistoryItem[]) => void, |
|
|
openPrivacyNotice: () => void, |
|
|
) => { |
|
|
const session = useSessionStats(); |
|
|
const gitService = useMemo(() => { |
|
|
if (!config?.getProjectRoot()) { |
|
|
return; |
|
|
} |
|
|
return new GitService(config.getProjectRoot()); |
|
|
}, [config]); |
|
|
|
|
|
const pendingHistoryItems: HistoryItemWithoutId[] = []; |
|
|
const [pendingCompressionItemRef, setPendingCompressionItem] = |
|
|
useStateAndRef<HistoryItemWithoutId | null>(null); |
|
|
if (pendingCompressionItemRef.current != null) { |
|
|
pendingHistoryItems.push(pendingCompressionItemRef.current); |
|
|
} |
|
|
|
|
|
const addMessage = useCallback( |
|
|
(message: Message) => { |
|
|
|
|
|
let historyItemContent: HistoryItemWithoutId; |
|
|
if (message.type === MessageType.ABOUT) { |
|
|
historyItemContent = { |
|
|
type: 'about', |
|
|
cliVersion: message.cliVersion, |
|
|
osVersion: message.osVersion, |
|
|
sandboxEnv: message.sandboxEnv, |
|
|
modelVersion: message.modelVersion, |
|
|
selectedAuthType: message.selectedAuthType, |
|
|
gcpProject: message.gcpProject, |
|
|
}; |
|
|
} else if (message.type === MessageType.STATS) { |
|
|
historyItemContent = { |
|
|
type: 'stats', |
|
|
stats: message.stats, |
|
|
lastTurnStats: message.lastTurnStats, |
|
|
duration: message.duration, |
|
|
}; |
|
|
} else if (message.type === MessageType.QUIT) { |
|
|
historyItemContent = { |
|
|
type: 'quit', |
|
|
stats: message.stats, |
|
|
duration: message.duration, |
|
|
}; |
|
|
} else if (message.type === MessageType.COMPRESSION) { |
|
|
historyItemContent = { |
|
|
type: 'compression', |
|
|
compression: message.compression, |
|
|
}; |
|
|
} else { |
|
|
historyItemContent = { |
|
|
type: message.type as |
|
|
| MessageType.INFO |
|
|
| MessageType.ERROR |
|
|
| MessageType.USER, |
|
|
text: message.content, |
|
|
}; |
|
|
} |
|
|
addItem(historyItemContent, message.timestamp.getTime()); |
|
|
}, |
|
|
[addItem], |
|
|
); |
|
|
|
|
|
const showMemoryAction = useCallback(async () => { |
|
|
const actionFn = createShowMemoryAction(config, settings, addMessage); |
|
|
await actionFn(); |
|
|
}, [config, settings, addMessage]); |
|
|
|
|
|
const addMemoryAction = useCallback( |
|
|
( |
|
|
_mainCommand: string, |
|
|
_subCommand?: string, |
|
|
args?: string, |
|
|
): SlashCommandActionReturn | void => { |
|
|
if (!args || args.trim() === '') { |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: 'Usage: /memory add <text to remember>', |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
} |
|
|
|
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: `Attempting to save to memory: "${args.trim()}"`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
|
|
|
return { |
|
|
shouldScheduleTool: true, |
|
|
toolName: 'save_memory', |
|
|
toolArgs: { fact: args.trim() }, |
|
|
}; |
|
|
}, |
|
|
[addMessage], |
|
|
); |
|
|
|
|
|
const savedChatTags = useCallback(async () => { |
|
|
const geminiDir = config?.getProjectTempDir(); |
|
|
if (!geminiDir) { |
|
|
return []; |
|
|
} |
|
|
try { |
|
|
const files = await fs.readdir(geminiDir); |
|
|
return files |
|
|
.filter( |
|
|
(file) => file.startsWith('checkpoint-') && file.endsWith('.json'), |
|
|
) |
|
|
.map((file) => file.replace('checkpoint-', '').replace('.json', '')); |
|
|
} catch (_err) { |
|
|
return []; |
|
|
} |
|
|
}, [config]); |
|
|
|
|
|
const slashCommands: SlashCommand[] = useMemo(() => { |
|
|
const commands: SlashCommand[] = [ |
|
|
{ |
|
|
name: 'help', |
|
|
altName: '?', |
|
|
description: 'for help on gemini-cli', |
|
|
action: (_mainCommand, _subCommand, _args) => { |
|
|
onDebugMessage('Opening help.'); |
|
|
setShowHelp(true); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'docs', |
|
|
description: 'open full Gemini CLI documentation in your browser', |
|
|
action: async (_mainCommand, _subCommand, _args) => { |
|
|
const docsUrl = 'https://goo.gle/gemini-cli-docs'; |
|
|
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { |
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
} else { |
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: `Opening documentation in your browser: ${docsUrl}`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
await open(docsUrl); |
|
|
} |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'clear', |
|
|
description: 'clear the screen and conversation history', |
|
|
action: async (_mainCommand, _subCommand, _args) => { |
|
|
onDebugMessage('Clearing terminal and resetting chat.'); |
|
|
clearItems(); |
|
|
await config?.getGeminiClient()?.resetChat(); |
|
|
console.clear(); |
|
|
refreshStatic(); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'theme', |
|
|
description: 'change the theme', |
|
|
action: (_mainCommand, _subCommand, _args) => { |
|
|
openThemeDialog(); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'auth', |
|
|
description: 'change the auth method', |
|
|
action: (_mainCommand, _subCommand, _args) => { |
|
|
openAuthDialog(); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'editor', |
|
|
description: 'set external editor preference', |
|
|
action: (_mainCommand, _subCommand, _args) => { |
|
|
openEditorDialog(); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'privacy', |
|
|
description: 'display the privacy notice', |
|
|
action: (_mainCommand, _subCommand, _args) => { |
|
|
openPrivacyNotice(); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'stats', |
|
|
altName: 'usage', |
|
|
description: 'check session stats', |
|
|
action: (_mainCommand, _subCommand, _args) => { |
|
|
const now = new Date(); |
|
|
const { sessionStartTime, cumulative, currentTurn } = session.stats; |
|
|
const wallDuration = now.getTime() - sessionStartTime.getTime(); |
|
|
|
|
|
addMessage({ |
|
|
type: MessageType.STATS, |
|
|
stats: cumulative, |
|
|
lastTurnStats: currentTurn, |
|
|
duration: formatDuration(wallDuration), |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'mcp', |
|
|
description: 'list configured MCP servers and tools', |
|
|
action: async (_mainCommand, _subCommand, _args) => { |
|
|
|
|
|
let useShowDescriptions = showToolDescriptions; |
|
|
if (_subCommand === 'desc' || _subCommand === 'descriptions') { |
|
|
useShowDescriptions = true; |
|
|
} else if ( |
|
|
_subCommand === 'nodesc' || |
|
|
_subCommand === 'nodescriptions' |
|
|
) { |
|
|
useShowDescriptions = false; |
|
|
} else if (_args === 'desc' || _args === 'descriptions') { |
|
|
useShowDescriptions = true; |
|
|
} else if (_args === 'nodesc' || _args === 'nodescriptions') { |
|
|
useShowDescriptions = false; |
|
|
} |
|
|
|
|
|
let useShowSchema = false; |
|
|
if (_subCommand === 'schema' || _args === 'schema') { |
|
|
useShowSchema = true; |
|
|
} |
|
|
|
|
|
const toolRegistry = await config?.getToolRegistry(); |
|
|
if (!toolRegistry) { |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: 'Could not retrieve tool registry.', |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
} |
|
|
|
|
|
const mcpServers = config?.getMcpServers() || {}; |
|
|
const serverNames = Object.keys(mcpServers); |
|
|
|
|
|
if (serverNames.length === 0) { |
|
|
const docsUrl = 'https://goo.gle/gemini-cli-docs-mcp'; |
|
|
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { |
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: `No MCP servers configured. Please open the following URL in your browser to view documentation:\n${docsUrl}`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
} else { |
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: `No MCP servers configured. Opening documentation in your browser: ${docsUrl}`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
await open(docsUrl); |
|
|
} |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const connectingServers = serverNames.filter( |
|
|
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING, |
|
|
); |
|
|
const discoveryState = getMCPDiscoveryState(); |
|
|
|
|
|
let message = ''; |
|
|
|
|
|
|
|
|
if ( |
|
|
discoveryState === MCPDiscoveryState.IN_PROGRESS || |
|
|
connectingServers.length > 0 |
|
|
) { |
|
|
message += `\u001b[33m⏳ MCP servers are starting up (${connectingServers.length} initializing)...\u001b[0m\n`; |
|
|
message += `\u001b[90mNote: First startup may take longer. Tool availability will update automatically.\u001b[0m\n\n`; |
|
|
} |
|
|
|
|
|
message += 'Configured MCP servers:\n\n'; |
|
|
|
|
|
for (const serverName of serverNames) { |
|
|
const serverTools = toolRegistry.getToolsByServer(serverName); |
|
|
const status = getMCPServerStatus(serverName); |
|
|
|
|
|
|
|
|
let statusIndicator = ''; |
|
|
let statusText = ''; |
|
|
switch (status) { |
|
|
case MCPServerStatus.CONNECTED: |
|
|
statusIndicator = '🟢'; |
|
|
statusText = 'Ready'; |
|
|
break; |
|
|
case MCPServerStatus.CONNECTING: |
|
|
statusIndicator = '🔄'; |
|
|
statusText = 'Starting... (first startup may take longer)'; |
|
|
break; |
|
|
case MCPServerStatus.DISCONNECTED: |
|
|
default: |
|
|
statusIndicator = '🔴'; |
|
|
statusText = 'Disconnected'; |
|
|
break; |
|
|
} |
|
|
|
|
|
|
|
|
const server = mcpServers[serverName]; |
|
|
|
|
|
|
|
|
message += `${statusIndicator} \u001b[1m${serverName}\u001b[0m - ${statusText}`; |
|
|
|
|
|
|
|
|
if (status === MCPServerStatus.CONNECTED) { |
|
|
message += ` (${serverTools.length} tools)`; |
|
|
} else if (status === MCPServerStatus.CONNECTING) { |
|
|
message += ` (tools will appear when ready)`; |
|
|
} else { |
|
|
message += ` (${serverTools.length} tools cached)`; |
|
|
} |
|
|
|
|
|
|
|
|
if ((useShowDescriptions || useShowSchema) && server?.description) { |
|
|
const greenColor = '\u001b[32m'; |
|
|
const resetColor = '\u001b[0m'; |
|
|
|
|
|
const descLines = server.description.trim().split('\n'); |
|
|
if (descLines) { |
|
|
message += ':\n'; |
|
|
for (let i = 0; i < descLines.length; i++) { |
|
|
message += ` ${greenColor}${descLines[i]}${resetColor}\n`; |
|
|
} |
|
|
} else { |
|
|
message += '\n'; |
|
|
} |
|
|
} else { |
|
|
message += '\n'; |
|
|
} |
|
|
|
|
|
|
|
|
message += '\u001b[0m'; |
|
|
|
|
|
if (serverTools.length > 0) { |
|
|
serverTools.forEach((tool) => { |
|
|
if ( |
|
|
(useShowDescriptions || useShowSchema) && |
|
|
tool.description |
|
|
) { |
|
|
|
|
|
message += ` - \u001b[36m${tool.name}\u001b[0m`; |
|
|
|
|
|
|
|
|
const greenColor = '\u001b[32m'; |
|
|
const resetColor = '\u001b[0m'; |
|
|
|
|
|
|
|
|
const descLines = tool.description.trim().split('\n'); |
|
|
if (descLines) { |
|
|
message += ':\n'; |
|
|
for (let i = 0; i < descLines.length; i++) { |
|
|
message += ` ${greenColor}${descLines[i]}${resetColor}\n`; |
|
|
} |
|
|
} else { |
|
|
message += '\n'; |
|
|
} |
|
|
|
|
|
} else { |
|
|
|
|
|
message += ` - \u001b[36m${tool.name}\u001b[0m\n`; |
|
|
} |
|
|
if (useShowSchema) { |
|
|
|
|
|
message += ` \u001b[36mParameters:\u001b[0m\n`; |
|
|
|
|
|
const greenColor = '\u001b[32m'; |
|
|
const resetColor = '\u001b[0m'; |
|
|
|
|
|
const paramsLines = JSON.stringify( |
|
|
tool.schema.parameters, |
|
|
null, |
|
|
2, |
|
|
) |
|
|
.trim() |
|
|
.split('\n'); |
|
|
if (paramsLines) { |
|
|
for (let i = 0; i < paramsLines.length; i++) { |
|
|
message += ` ${greenColor}${paramsLines[i]}${resetColor}\n`; |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
} else { |
|
|
message += ' No tools available\n'; |
|
|
} |
|
|
message += '\n'; |
|
|
} |
|
|
|
|
|
|
|
|
message += '\u001b[0m'; |
|
|
|
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: message, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'memory', |
|
|
description: |
|
|
'manage memory. Usage: /memory <show|refresh|add> [text for add]', |
|
|
action: (mainCommand, subCommand, args) => { |
|
|
switch (subCommand) { |
|
|
case 'show': |
|
|
showMemoryAction(); |
|
|
return; |
|
|
case 'refresh': |
|
|
performMemoryRefresh(); |
|
|
return; |
|
|
case 'add': |
|
|
return addMemoryAction(mainCommand, subCommand, args); |
|
|
case undefined: |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: |
|
|
'Missing command\nUsage: /memory <show|refresh|add> [text for add]', |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
default: |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: `Unknown /memory command: ${subCommand}. Available: show, refresh, add`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
} |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'tools', |
|
|
description: 'list available Gemini CLI tools', |
|
|
action: async (_mainCommand, _subCommand, _args) => { |
|
|
|
|
|
let useShowDescriptions = showToolDescriptions; |
|
|
if (_subCommand === 'desc' || _subCommand === 'descriptions') { |
|
|
useShowDescriptions = true; |
|
|
} else if ( |
|
|
_subCommand === 'nodesc' || |
|
|
_subCommand === 'nodescriptions' |
|
|
) { |
|
|
useShowDescriptions = false; |
|
|
} else if (_args === 'desc' || _args === 'descriptions') { |
|
|
useShowDescriptions = true; |
|
|
} else if (_args === 'nodesc' || _args === 'nodescriptions') { |
|
|
useShowDescriptions = false; |
|
|
} |
|
|
|
|
|
const toolRegistry = await config?.getToolRegistry(); |
|
|
const tools = toolRegistry?.getAllTools(); |
|
|
if (!tools) { |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: 'Could not retrieve tools.', |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const geminiTools = tools.filter((tool) => !('serverName' in tool)); |
|
|
|
|
|
let message = 'Available Gemini CLI tools:\n\n'; |
|
|
|
|
|
if (geminiTools.length > 0) { |
|
|
geminiTools.forEach((tool) => { |
|
|
if (useShowDescriptions && tool.description) { |
|
|
|
|
|
message += ` - \u001b[36m${tool.displayName} (${tool.name})\u001b[0m:\n`; |
|
|
|
|
|
|
|
|
const greenColor = '\u001b[32m'; |
|
|
const resetColor = '\u001b[0m'; |
|
|
|
|
|
|
|
|
const descLines = tool.description.trim().split('\n'); |
|
|
|
|
|
|
|
|
if (descLines) { |
|
|
for (let i = 0; i < descLines.length; i++) { |
|
|
message += ` ${greenColor}${descLines[i]}${resetColor}\n`; |
|
|
} |
|
|
} |
|
|
} else { |
|
|
|
|
|
message += ` - \u001b[36m${tool.displayName}\u001b[0m\n`; |
|
|
} |
|
|
}); |
|
|
} else { |
|
|
message += ' No tools available\n'; |
|
|
} |
|
|
message += '\n'; |
|
|
|
|
|
|
|
|
message += '\u001b[0m'; |
|
|
|
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: message, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'corgi', |
|
|
action: (_mainCommand, _subCommand, _args) => { |
|
|
toggleCorgiMode(); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'about', |
|
|
description: 'show version info', |
|
|
action: async (_mainCommand, _subCommand, _args) => { |
|
|
const osVersion = process.platform; |
|
|
let sandboxEnv = 'no sandbox'; |
|
|
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { |
|
|
sandboxEnv = process.env.SANDBOX; |
|
|
} else if (process.env.SANDBOX === 'sandbox-exec') { |
|
|
sandboxEnv = `sandbox-exec (${ |
|
|
process.env.SEATBELT_PROFILE || 'unknown' |
|
|
})`; |
|
|
} |
|
|
const modelVersion = config?.getModel() || 'Unknown'; |
|
|
const cliVersion = await getCliVersion(); |
|
|
const selectedAuthType = settings.merged.selectedAuthType || ''; |
|
|
const gcpProject = process.env.GOOGLE_CLOUD_PROJECT || ''; |
|
|
addMessage({ |
|
|
type: MessageType.ABOUT, |
|
|
timestamp: new Date(), |
|
|
cliVersion, |
|
|
osVersion, |
|
|
sandboxEnv, |
|
|
modelVersion, |
|
|
selectedAuthType, |
|
|
gcpProject, |
|
|
}); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'bug', |
|
|
description: 'submit a bug report', |
|
|
action: async (_mainCommand, _subCommand, args) => { |
|
|
let bugDescription = _subCommand || ''; |
|
|
if (args) { |
|
|
bugDescription += ` ${args}`; |
|
|
} |
|
|
bugDescription = bugDescription.trim(); |
|
|
|
|
|
const osVersion = `${process.platform} ${process.version}`; |
|
|
let sandboxEnv = 'no sandbox'; |
|
|
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { |
|
|
sandboxEnv = process.env.SANDBOX.replace(/^gemini-(?:code-)?/, ''); |
|
|
} else if (process.env.SANDBOX === 'sandbox-exec') { |
|
|
sandboxEnv = `sandbox-exec (${ |
|
|
process.env.SEATBELT_PROFILE || 'unknown' |
|
|
})`; |
|
|
} |
|
|
const modelVersion = config?.getModel() || 'Unknown'; |
|
|
const cliVersion = await getCliVersion(); |
|
|
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss); |
|
|
|
|
|
const info = ` |
|
|
* **CLI Version:** ${cliVersion} |
|
|
* **Git Commit:** ${GIT_COMMIT_INFO} |
|
|
* **Operating System:** ${osVersion} |
|
|
* **Sandbox Environment:** ${sandboxEnv} |
|
|
* **Model Version:** ${modelVersion} |
|
|
* **Memory Usage:** ${memoryUsage} |
|
|
`; |
|
|
|
|
|
let bugReportUrl = |
|
|
'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}'; |
|
|
const bugCommand = config?.getBugCommand(); |
|
|
if (bugCommand?.urlTemplate) { |
|
|
bugReportUrl = bugCommand.urlTemplate; |
|
|
} |
|
|
bugReportUrl = bugReportUrl |
|
|
.replace('{title}', encodeURIComponent(bugDescription)) |
|
|
.replace('{info}', encodeURIComponent(info)); |
|
|
|
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
(async () => { |
|
|
try { |
|
|
await open(bugReportUrl); |
|
|
} catch (error) { |
|
|
const errorMessage = |
|
|
error instanceof Error ? error.message : String(error); |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: `Could not open URL in browser: ${errorMessage}`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
} |
|
|
})(); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'chat', |
|
|
description: |
|
|
'Manage conversation history. Usage: /chat <list|save|resume> [tag]', |
|
|
action: async (_mainCommand, subCommand, args) => { |
|
|
const tag = (args || '').trim(); |
|
|
const logger = new Logger(config?.getSessionId() || ''); |
|
|
await logger.initialize(); |
|
|
const chat = await config?.getGeminiClient()?.getChat(); |
|
|
if (!chat) { |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: 'No chat client available for conversation status.', |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
} |
|
|
if (!subCommand) { |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: 'Missing command\nUsage: /chat <list|save|resume> [tag]', |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
} |
|
|
switch (subCommand) { |
|
|
case 'save': { |
|
|
const history = chat.getHistory(); |
|
|
if (history.length > 0) { |
|
|
await logger.saveCheckpoint(chat?.getHistory() || [], tag); |
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: `Conversation checkpoint saved${tag ? ' with tag: ' + tag : ''}.`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
} else { |
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: 'No conversation found to save.', |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
} |
|
|
return; |
|
|
} |
|
|
case 'resume': |
|
|
case 'restore': |
|
|
case 'load': { |
|
|
const conversation = await logger.loadCheckpoint(tag); |
|
|
if (conversation.length === 0) { |
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: `No saved checkpoint found${tag ? ' with tag: ' + tag : ''}.`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
} |
|
|
|
|
|
clearItems(); |
|
|
chat.clearHistory(); |
|
|
const rolemap: { [key: string]: MessageType } = { |
|
|
user: MessageType.USER, |
|
|
model: MessageType.GEMINI, |
|
|
}; |
|
|
let hasSystemPrompt = false; |
|
|
let i = 0; |
|
|
for (const item of conversation) { |
|
|
i += 1; |
|
|
|
|
|
|
|
|
|
|
|
chat.addHistory(item); |
|
|
|
|
|
const text = |
|
|
item.parts |
|
|
?.filter((m) => !!m.text) |
|
|
.map((m) => m.text) |
|
|
.join('') || ''; |
|
|
if (!text) { |
|
|
|
|
|
continue; |
|
|
} |
|
|
if (i === 1 && text.match(/context for our chat/)) { |
|
|
hasSystemPrompt = true; |
|
|
} |
|
|
if (i > 2 || !hasSystemPrompt) { |
|
|
addItem( |
|
|
{ |
|
|
type: |
|
|
(item.role && rolemap[item.role]) || MessageType.GEMINI, |
|
|
text, |
|
|
} as HistoryItemWithoutId, |
|
|
i, |
|
|
); |
|
|
} |
|
|
} |
|
|
console.clear(); |
|
|
refreshStatic(); |
|
|
return; |
|
|
} |
|
|
case 'list': |
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: |
|
|
'list of saved conversations: ' + |
|
|
(await savedChatTags()).join(', '), |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
default: |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: `Unknown /chat command: ${subCommand}. Available: list, save, resume`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
} |
|
|
}, |
|
|
completion: async () => |
|
|
(await savedChatTags()).map((tag) => 'resume ' + tag), |
|
|
}, |
|
|
{ |
|
|
name: 'quit', |
|
|
altName: 'exit', |
|
|
description: 'exit the cli', |
|
|
action: async (mainCommand, _subCommand, _args) => { |
|
|
const now = new Date(); |
|
|
const { sessionStartTime, cumulative } = session.stats; |
|
|
const wallDuration = now.getTime() - sessionStartTime.getTime(); |
|
|
|
|
|
setQuittingMessages([ |
|
|
{ |
|
|
type: 'user', |
|
|
text: `/${mainCommand}`, |
|
|
id: now.getTime() - 1, |
|
|
}, |
|
|
{ |
|
|
type: 'quit', |
|
|
stats: cumulative, |
|
|
duration: formatDuration(wallDuration), |
|
|
id: now.getTime(), |
|
|
}, |
|
|
]); |
|
|
|
|
|
setTimeout(() => { |
|
|
process.exit(0); |
|
|
}, 100); |
|
|
}, |
|
|
}, |
|
|
{ |
|
|
name: 'compress', |
|
|
altName: 'summarize', |
|
|
description: 'Compresses the context by replacing it with a summary.', |
|
|
action: async (_mainCommand, _subCommand, _args) => { |
|
|
if (pendingCompressionItemRef.current !== null) { |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: |
|
|
'Already compressing, wait for previous request to complete', |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
} |
|
|
setPendingCompressionItem({ |
|
|
type: MessageType.COMPRESSION, |
|
|
compression: { |
|
|
isPending: true, |
|
|
originalTokenCount: null, |
|
|
newTokenCount: null, |
|
|
}, |
|
|
}); |
|
|
try { |
|
|
const compressed = await config! |
|
|
.getGeminiClient()! |
|
|
.tryCompressChat(true); |
|
|
if (compressed) { |
|
|
addMessage({ |
|
|
type: MessageType.COMPRESSION, |
|
|
compression: { |
|
|
isPending: false, |
|
|
originalTokenCount: compressed.originalTokenCount, |
|
|
newTokenCount: compressed.newTokenCount, |
|
|
}, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
} else { |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: 'Failed to compress chat history.', |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
} |
|
|
} catch (e) { |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: `Failed to compress chat history: ${e instanceof Error ? e.message : String(e)}`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
} |
|
|
setPendingCompressionItem(null); |
|
|
}, |
|
|
}, |
|
|
]; |
|
|
|
|
|
if (config?.getCheckpointingEnabled()) { |
|
|
commands.push({ |
|
|
name: 'restore', |
|
|
description: |
|
|
'restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested', |
|
|
completion: async () => { |
|
|
const checkpointDir = config?.getProjectTempDir() |
|
|
? path.join(config.getProjectTempDir(), 'checkpoints') |
|
|
: undefined; |
|
|
if (!checkpointDir) { |
|
|
return []; |
|
|
} |
|
|
try { |
|
|
const files = await fs.readdir(checkpointDir); |
|
|
return files |
|
|
.filter((file) => file.endsWith('.json')) |
|
|
.map((file) => file.replace('.json', '')); |
|
|
} catch (_err) { |
|
|
return []; |
|
|
} |
|
|
}, |
|
|
action: async (_mainCommand, subCommand, _args) => { |
|
|
const checkpointDir = config?.getProjectTempDir() |
|
|
? path.join(config.getProjectTempDir(), 'checkpoints') |
|
|
: undefined; |
|
|
|
|
|
if (!checkpointDir) { |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: 'Could not determine the .gemini directory path.', |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
} |
|
|
|
|
|
try { |
|
|
|
|
|
await fs.mkdir(checkpointDir, { recursive: true }); |
|
|
const files = await fs.readdir(checkpointDir); |
|
|
const jsonFiles = files.filter((file) => file.endsWith('.json')); |
|
|
|
|
|
if (!subCommand) { |
|
|
if (jsonFiles.length === 0) { |
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: 'No restorable tool calls found.', |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
} |
|
|
const truncatedFiles = jsonFiles.map((file) => { |
|
|
const components = file.split('.'); |
|
|
if (components.length <= 1) { |
|
|
return file; |
|
|
} |
|
|
components.pop(); |
|
|
return components.join('.'); |
|
|
}); |
|
|
const fileList = truncatedFiles.join('\n'); |
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: `Available tool calls to restore:\n\n${fileList}`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
} |
|
|
|
|
|
const selectedFile = subCommand.endsWith('.json') |
|
|
? subCommand |
|
|
: `${subCommand}.json`; |
|
|
|
|
|
if (!jsonFiles.includes(selectedFile)) { |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: `File not found: ${selectedFile}`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return; |
|
|
} |
|
|
|
|
|
const filePath = path.join(checkpointDir, selectedFile); |
|
|
const data = await fs.readFile(filePath, 'utf-8'); |
|
|
const toolCallData = JSON.parse(data); |
|
|
|
|
|
if (toolCallData.history) { |
|
|
loadHistory(toolCallData.history); |
|
|
} |
|
|
|
|
|
if (toolCallData.clientHistory) { |
|
|
await config |
|
|
?.getGeminiClient() |
|
|
?.setHistory(toolCallData.clientHistory); |
|
|
} |
|
|
|
|
|
if (toolCallData.commitHash) { |
|
|
await gitService?.restoreProjectFromSnapshot( |
|
|
toolCallData.commitHash, |
|
|
); |
|
|
addMessage({ |
|
|
type: MessageType.INFO, |
|
|
content: `Restored project to the state before the tool call.`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
} |
|
|
|
|
|
return { |
|
|
shouldScheduleTool: true, |
|
|
toolName: toolCallData.toolCall.name, |
|
|
toolArgs: toolCallData.toolCall.args, |
|
|
}; |
|
|
} catch (error) { |
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: `Could not read restorable tool calls. This is the error: ${error}`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
} |
|
|
}, |
|
|
}); |
|
|
} |
|
|
return commands; |
|
|
}, [ |
|
|
onDebugMessage, |
|
|
setShowHelp, |
|
|
refreshStatic, |
|
|
openThemeDialog, |
|
|
openAuthDialog, |
|
|
openEditorDialog, |
|
|
clearItems, |
|
|
performMemoryRefresh, |
|
|
showMemoryAction, |
|
|
addMemoryAction, |
|
|
addMessage, |
|
|
toggleCorgiMode, |
|
|
savedChatTags, |
|
|
config, |
|
|
settings, |
|
|
showToolDescriptions, |
|
|
session, |
|
|
gitService, |
|
|
loadHistory, |
|
|
addItem, |
|
|
setQuittingMessages, |
|
|
pendingCompressionItemRef, |
|
|
setPendingCompressionItem, |
|
|
openPrivacyNotice, |
|
|
]); |
|
|
|
|
|
const handleSlashCommand = useCallback( |
|
|
async ( |
|
|
rawQuery: PartListUnion, |
|
|
): Promise<SlashCommandActionReturn | boolean> => { |
|
|
if (typeof rawQuery !== 'string') { |
|
|
return false; |
|
|
} |
|
|
const trimmed = rawQuery.trim(); |
|
|
if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) { |
|
|
return false; |
|
|
} |
|
|
const userMessageTimestamp = Date.now(); |
|
|
if (trimmed !== '/quit' && trimmed !== '/exit') { |
|
|
addItem( |
|
|
{ type: MessageType.USER, text: trimmed }, |
|
|
userMessageTimestamp, |
|
|
); |
|
|
} |
|
|
|
|
|
let subCommand: string | undefined; |
|
|
let args: string | undefined; |
|
|
|
|
|
const commandToMatch = (() => { |
|
|
if (trimmed.startsWith('?')) { |
|
|
return 'help'; |
|
|
} |
|
|
const parts = trimmed.substring(1).trim().split(/\s+/); |
|
|
if (parts.length > 1) { |
|
|
subCommand = parts[1]; |
|
|
} |
|
|
if (parts.length > 2) { |
|
|
args = parts.slice(2).join(' '); |
|
|
} |
|
|
return parts[0]; |
|
|
})(); |
|
|
|
|
|
const mainCommand = commandToMatch; |
|
|
|
|
|
for (const cmd of slashCommands) { |
|
|
if (mainCommand === cmd.name || mainCommand === cmd.altName) { |
|
|
const actionResult = await cmd.action(mainCommand, subCommand, args); |
|
|
if ( |
|
|
typeof actionResult === 'object' && |
|
|
actionResult?.shouldScheduleTool |
|
|
) { |
|
|
return actionResult; |
|
|
} |
|
|
return true; |
|
|
} |
|
|
} |
|
|
|
|
|
addMessage({ |
|
|
type: MessageType.ERROR, |
|
|
content: `Unknown command: ${trimmed}`, |
|
|
timestamp: new Date(), |
|
|
}); |
|
|
return true; |
|
|
}, |
|
|
[addItem, slashCommands, addMessage], |
|
|
); |
|
|
|
|
|
return { handleSlashCommand, slashCommands, pendingHistoryItems }; |
|
|
}; |
|
|
|