mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-12 07:01:09 -07:00
172 lines
4.7 KiB
TypeScript
172 lines
4.7 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {
|
|
coreEvents,
|
|
CoreEvent,
|
|
type SlashCommandConflictsPayload,
|
|
type SlashCommandConflict,
|
|
} from '@google/gemini-cli-core';
|
|
import { CommandKind } from '../ui/commands/types.js';
|
|
|
|
/**
|
|
* Handles slash command conflict events and provides user feedback.
|
|
*
|
|
* This handler batches multiple conflict events into a single notification
|
|
* block per command name to avoid UI clutter during startup or incremental loading.
|
|
*/
|
|
export class SlashCommandConflictHandler {
|
|
private notifiedConflicts = new Set<string>();
|
|
private pendingConflicts: SlashCommandConflict[] = [];
|
|
private flushTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
constructor() {
|
|
this.handleConflicts = this.handleConflicts.bind(this);
|
|
}
|
|
|
|
start() {
|
|
coreEvents.on(CoreEvent.SlashCommandConflicts, this.handleConflicts);
|
|
}
|
|
|
|
stop() {
|
|
coreEvents.off(CoreEvent.SlashCommandConflicts, this.handleConflicts);
|
|
if (this.flushTimeout) {
|
|
clearTimeout(this.flushTimeout);
|
|
this.flushTimeout = null;
|
|
}
|
|
}
|
|
|
|
private handleConflicts(payload: SlashCommandConflictsPayload) {
|
|
const newConflicts = payload.conflicts.filter((c) => {
|
|
// Use a unique key to prevent duplicate notifications for the same conflict
|
|
const sourceId =
|
|
c.loserExtensionName || c.loserMcpServerName || c.loserKind;
|
|
const key = `${c.name}:${sourceId}:${c.renamedTo}`;
|
|
if (this.notifiedConflicts.has(key)) {
|
|
return false;
|
|
}
|
|
this.notifiedConflicts.add(key);
|
|
return true;
|
|
});
|
|
|
|
if (newConflicts.length > 0) {
|
|
this.pendingConflicts.push(...newConflicts);
|
|
this.scheduleFlush();
|
|
}
|
|
}
|
|
|
|
private scheduleFlush() {
|
|
if (this.flushTimeout) {
|
|
clearTimeout(this.flushTimeout);
|
|
}
|
|
// Use a trailing debounce to capture staggered reloads during startup
|
|
this.flushTimeout = setTimeout(() => this.flush(), 500);
|
|
}
|
|
|
|
private flush() {
|
|
this.flushTimeout = null;
|
|
const conflicts = [...this.pendingConflicts];
|
|
this.pendingConflicts = [];
|
|
|
|
if (conflicts.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Group conflicts by their original command name
|
|
const grouped = new Map<string, SlashCommandConflict[]>();
|
|
for (const c of conflicts) {
|
|
const list = grouped.get(c.name) ?? [];
|
|
list.push(c);
|
|
grouped.set(c.name, list);
|
|
}
|
|
|
|
for (const [name, commandConflicts] of grouped) {
|
|
if (commandConflicts.length > 1) {
|
|
this.emitGroupedFeedback(name, commandConflicts);
|
|
} else {
|
|
this.emitSingleFeedback(commandConflicts[0]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emits a grouped notification for multiple conflicts sharing the same name.
|
|
*/
|
|
private emitGroupedFeedback(
|
|
name: string,
|
|
conflicts: SlashCommandConflict[],
|
|
): void {
|
|
const messages = conflicts
|
|
.map((c) => {
|
|
const source = this.getSourceDescription(
|
|
c.loserExtensionName,
|
|
c.loserKind,
|
|
c.loserMcpServerName,
|
|
);
|
|
return `- ${this.capitalize(source)} '/${c.name}' was renamed to '/${c.renamedTo}'`;
|
|
})
|
|
.join('\n');
|
|
|
|
coreEvents.emitFeedback(
|
|
'info',
|
|
`Conflicts detected for command '/${name}':\n${messages}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Emits a descriptive notification for a single command conflict.
|
|
*/
|
|
private emitSingleFeedback(c: SlashCommandConflict): void {
|
|
const loserSource = this.getSourceDescription(
|
|
c.loserExtensionName,
|
|
c.loserKind,
|
|
c.loserMcpServerName,
|
|
);
|
|
const winnerSource = this.getSourceDescription(
|
|
c.winnerExtensionName,
|
|
c.winnerKind,
|
|
c.winnerMcpServerName,
|
|
);
|
|
|
|
coreEvents.emitFeedback(
|
|
'info',
|
|
`${this.capitalize(loserSource)} '/${c.name}' was renamed to '/${c.renamedTo}' because it conflicts with ${winnerSource}.`,
|
|
);
|
|
}
|
|
|
|
private capitalize(s: string): string {
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
}
|
|
|
|
/**
|
|
* Returns a human-readable description of a command's source.
|
|
*/
|
|
private getSourceDescription(
|
|
extensionName?: string,
|
|
kind?: string,
|
|
mcpServerName?: string,
|
|
): string {
|
|
switch (kind) {
|
|
case CommandKind.EXTENSION_FILE:
|
|
return extensionName
|
|
? `extension '${extensionName}' command`
|
|
: 'extension command';
|
|
case CommandKind.MCP_PROMPT:
|
|
return mcpServerName
|
|
? `MCP server '${mcpServerName}' command`
|
|
: 'MCP server command';
|
|
case CommandKind.USER_FILE:
|
|
return 'user command';
|
|
case CommandKind.WORKSPACE_FILE:
|
|
return 'workspace command';
|
|
case CommandKind.BUILT_IN:
|
|
return 'built-in command';
|
|
default:
|
|
return 'existing command';
|
|
}
|
|
}
|
|
}
|