Merge remote-tracking branch 'origin/main' into mk-packing

This commit is contained in:
mkorwel
2025-07-01 18:57:38 -05:00
22 changed files with 577 additions and 159 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.1.8",
"version": "0.1.9",
"description": "Gemini CLI",
"repository": "google-gemini/gemini-cli",
"type": "module",

View File

@@ -350,3 +350,131 @@ describe('mergeMcpServers', () => {
expect(settings).toEqual(originalSettings);
});
});
describe('mergeExcludeTools', () => {
it('should merge excludeTools from settings and extensions', async () => {
const settings: Settings = { excludeTools: ['tool1', 'tool2'] };
const extensions: Extension[] = [
{
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool3', 'tool4'],
},
contextFiles: [],
},
{
config: {
name: 'ext2',
version: '1.0.0',
excludeTools: ['tool5'],
},
contextFiles: [],
},
];
const config = await loadCliConfig(settings, extensions, 'test-session');
expect(config.getExcludeTools()).toEqual(
expect.arrayContaining(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
);
expect(config.getExcludeTools()).toHaveLength(5);
});
it('should handle overlapping excludeTools between settings and extensions', async () => {
const settings: Settings = { excludeTools: ['tool1', 'tool2'] };
const extensions: Extension[] = [
{
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool2', 'tool3'],
},
contextFiles: [],
},
];
const config = await loadCliConfig(settings, extensions, 'test-session');
expect(config.getExcludeTools()).toEqual(
expect.arrayContaining(['tool1', 'tool2', 'tool3']),
);
expect(config.getExcludeTools()).toHaveLength(3);
});
it('should handle overlapping excludeTools between extensions', async () => {
const settings: Settings = { excludeTools: ['tool1'] };
const extensions: Extension[] = [
{
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool2', 'tool3'],
},
contextFiles: [],
},
{
config: {
name: 'ext2',
version: '1.0.0',
excludeTools: ['tool3', 'tool4'],
},
contextFiles: [],
},
];
const config = await loadCliConfig(settings, extensions, 'test-session');
expect(config.getExcludeTools()).toEqual(
expect.arrayContaining(['tool1', 'tool2', 'tool3', 'tool4']),
);
expect(config.getExcludeTools()).toHaveLength(4);
});
it('should return an empty array when no excludeTools are specified', async () => {
const settings: Settings = {};
const extensions: Extension[] = [];
const config = await loadCliConfig(settings, extensions, 'test-session');
expect(config.getExcludeTools()).toEqual([]);
});
it('should handle settings with excludeTools but no extensions', async () => {
const settings: Settings = { excludeTools: ['tool1', 'tool2'] };
const extensions: Extension[] = [];
const config = await loadCliConfig(settings, extensions, 'test-session');
expect(config.getExcludeTools()).toEqual(
expect.arrayContaining(['tool1', 'tool2']),
);
expect(config.getExcludeTools()).toHaveLength(2);
});
it('should handle extensions with excludeTools but no settings', async () => {
const settings: Settings = {};
const extensions: Extension[] = [
{
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool1', 'tool2'],
},
contextFiles: [],
},
];
const config = await loadCliConfig(settings, extensions, 'test-session');
expect(config.getExcludeTools()).toEqual(
expect.arrayContaining(['tool1', 'tool2']),
);
expect(config.getExcludeTools()).toHaveLength(2);
});
it('should not modify the original settings object', async () => {
const settings: Settings = { excludeTools: ['tool1'] };
const extensions: Extension[] = [
{
config: {
name: 'ext1',
version: '1.0.0',
excludeTools: ['tool2'],
},
contextFiles: [],
},
];
const originalSettings = JSON.parse(JSON.stringify(settings));
await loadCliConfig(settings, extensions, 'test-session');
expect(settings).toEqual(originalSettings);
});
});

View File

@@ -194,6 +194,7 @@ export async function loadCliConfig(
);
const mcpServers = mergeMcpServers(settings, extensions);
const excludeTools = mergeExcludeTools(settings, extensions);
const sandboxConfig = await loadSandboxConfig(settings, argv);
@@ -206,7 +207,7 @@ export async function loadCliConfig(
question: argv.prompt || '',
fullContext: argv.all_files || false,
coreTools: settings.coreTools || undefined,
excludeTools: settings.excludeTools || undefined,
excludeTools,
toolDiscoveryCommand: settings.toolDiscoveryCommand,
toolCallCommand: settings.toolCallCommand,
mcpServerCommand: settings.mcpServerCommand,
@@ -265,6 +266,20 @@ function mergeMcpServers(settings: Settings, extensions: Extension[]) {
}
return mcpServers;
}
function mergeExcludeTools(
settings: Settings,
extensions: Extension[],
): string[] {
const allExcludeTools = new Set(settings.excludeTools || []);
for (const extension of extensions) {
for (const tool of extension.config.excludeTools || []) {
allExcludeTools.add(tool);
}
}
return [...allExcludeTools];
}
function findEnvFile(startDir: string): string | null {
let currentDir = path.resolve(startDir);
while (true) {

View File

@@ -22,6 +22,7 @@ export interface ExtensionConfig {
version: string;
mcpServers?: Record<string, MCPServerConfig>;
contextFileName?: string | string[];
excludeTools?: string[];
}
export function loadExtensions(workspaceDir: string): Extension[] {

View File

@@ -113,7 +113,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
const query = buffer.text;
const selectedSuggestion = completionSuggestions[indexToUse];
const suggestion = completionSuggestions[indexToUse].value;
if (query.trimStart().startsWith('/')) {
const parts = query.trimStart().substring(1).split(' ');
@@ -122,11 +122,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const base = query.substring(0, slashIndex + 1);
const command = slashCommands.find((cmd) => cmd.name === commandName);
if (command && command.completion) {
const newValue = `${base}${commandName} ${selectedSuggestion.value}`;
buffer.setText(newValue);
// Make sure completion isn't the original command when command.completigion hasn't happened yet.
if (command && command.completion && suggestion !== commandName) {
const newValue = `${base}${commandName} ${suggestion}`;
if (newValue === query) {
handleSubmitAndClear(newValue);
} else {
buffer.setText(newValue);
}
} else {
const newValue = base + selectedSuggestion.value;
const newValue = base + suggestion;
buffer.setText(newValue);
handleSubmitAndClear(newValue);
}
@@ -142,7 +147,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.replaceRangeByOffset(
autoCompleteStartIndex,
buffer.text.length,
selectedSuggestion.value,
suggestion,
);
}
resetCompletionState();

View File

@@ -420,6 +420,7 @@ export function useTextBuffer({
const [undoStack, setUndoStack] = useState<UndoHistoryEntry[]>([]);
const [redoStack, setRedoStack] = useState<UndoHistoryEntry[]>([]);
const historyLimit = 100;
const [opQueue, setOpQueue] = useState<UpdateOperation[]>([]);
const [clipboard, setClipboard] = useState<string | null>(null);
const [selectionAnchor, setSelectionAnchor] = useState<
@@ -526,148 +527,110 @@ export function useTextBuffer({
return _restoreState(state);
}, [redoStack, lines, cursorRow, cursorCol, _restoreState]);
const insertStr = useCallback(
(str: string): boolean => {
dbg('insertStr', { str, beforeCursor: [cursorRow, cursorCol] });
if (str === '') return false;
const applyOperations = useCallback((ops: UpdateOperation[]) => {
if (ops.length === 0) return;
setOpQueue((prev) => [...prev, ...ops]);
}, []);
pushUndo();
let normalised = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
normalised = stripUnsafeCharacters(normalised);
useEffect(() => {
if (opQueue.length === 0) return;
const parts = normalised.split('\n');
const newLines = [...lines];
const lineContent = currentLine(cursorRow);
const before = cpSlice(lineContent, 0, cursorCol);
const after = cpSlice(lineContent, cursorCol);
newLines[cursorRow] = before + parts[0];
if (parts.length > 1) {
// Adjusted condition for inserting multiple lines
const remainingParts = parts.slice(1);
const lastPartOriginal = remainingParts.pop() ?? '';
newLines.splice(cursorRow + 1, 0, ...remainingParts);
newLines.splice(
cursorRow + parts.length - 1,
0,
lastPartOriginal + after,
);
setCursorRow(cursorRow + parts.length - 1);
setCursorCol(cpLen(lastPartOriginal));
} else {
setCursorCol(cpLen(before) + cpLen(parts[0]));
}
setLines(newLines);
setPreferredCol(null);
return true;
},
[pushUndo, cursorRow, cursorCol, lines, currentLine, setPreferredCol],
);
const applyOperations = useCallback(
(ops: UpdateOperation[]) => {
if (ops.length === 0) return;
const expandedOps: UpdateOperation[] = [];
for (const op of ops) {
if (op.type === 'insert') {
let currentText = '';
for (const char of toCodePoints(op.payload)) {
if (char.codePointAt(0) === 127) {
// \x7f
if (currentText.length > 0) {
expandedOps.push({ type: 'insert', payload: currentText });
currentText = '';
}
expandedOps.push({ type: 'backspace' });
} else {
currentText += char;
const expandedOps: UpdateOperation[] = [];
for (const op of opQueue) {
if (op.type === 'insert') {
let currentText = '';
for (const char of toCodePoints(op.payload)) {
if (char.codePointAt(0) === 127) {
// \x7f
if (currentText.length > 0) {
expandedOps.push({ type: 'insert', payload: currentText });
currentText = '';
}
}
if (currentText.length > 0) {
expandedOps.push({ type: 'insert', payload: currentText });
}
} else {
expandedOps.push(op);
}
}
if (expandedOps.length === 0) {
return;
}
pushUndo(); // Snapshot before applying batch of updates
const newLines = [...lines];
let newCursorRow = cursorRow;
let newCursorCol = cursorCol;
const currentLine = (r: number) => newLines[r] ?? '';
for (const op of expandedOps) {
if (op.type === 'insert') {
const str = stripUnsafeCharacters(
op.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'),
);
const parts = str.split('\n');
const lineContent = currentLine(newCursorRow);
const before = cpSlice(lineContent, 0, newCursorCol);
const after = cpSlice(lineContent, newCursorCol);
if (parts.length > 1) {
newLines[newCursorRow] = before + parts[0];
const remainingParts = parts.slice(1);
const lastPartOriginal = remainingParts.pop() ?? '';
newLines.splice(newCursorRow + 1, 0, ...remainingParts);
newLines.splice(
newCursorRow + parts.length - 1,
0,
lastPartOriginal + after,
);
newCursorRow = newCursorRow + parts.length - 1;
newCursorCol = cpLen(lastPartOriginal);
expandedOps.push({ type: 'backspace' });
} else {
newLines[newCursorRow] = before + parts[0] + after;
newCursorCol = cpLen(before) + cpLen(parts[0]);
}
} else if (op.type === 'backspace') {
if (newCursorCol === 0 && newCursorRow === 0) continue;
if (newCursorCol > 0) {
const lineContent = currentLine(newCursorRow);
newLines[newCursorRow] =
cpSlice(lineContent, 0, newCursorCol - 1) +
cpSlice(lineContent, newCursorCol);
newCursorCol--;
} else if (newCursorRow > 0) {
const prevLineContent = currentLine(newCursorRow - 1);
const currentLineContentVal = currentLine(newCursorRow);
const newCol = cpLen(prevLineContent);
newLines[newCursorRow - 1] =
prevLineContent + currentLineContentVal;
newLines.splice(newCursorRow, 1);
newCursorRow--;
newCursorCol = newCol;
currentText += char;
}
}
if (currentText.length > 0) {
expandedOps.push({ type: 'insert', payload: currentText });
}
} else {
expandedOps.push(op);
}
}
setLines(newLines);
setCursorRow(newCursorRow);
setCursorCol(newCursorCol);
setPreferredCol(null);
},
[lines, cursorRow, cursorCol, pushUndo, setPreferredCol],
);
if (expandedOps.length === 0) {
setOpQueue([]); // Clear queue even if ops were no-ops
return;
}
pushUndo(); // Snapshot before applying batch of updates
const newLines = [...lines];
let newCursorRow = cursorRow;
let newCursorCol = cursorCol;
const currentLine = (r: number) => newLines[r] ?? '';
for (const op of expandedOps) {
if (op.type === 'insert') {
const str = stripUnsafeCharacters(
op.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'),
);
const parts = str.split('\n');
const lineContent = currentLine(newCursorRow);
const before = cpSlice(lineContent, 0, newCursorCol);
const after = cpSlice(lineContent, newCursorCol);
if (parts.length > 1) {
newLines[newCursorRow] = before + parts[0];
const remainingParts = parts.slice(1);
const lastPartOriginal = remainingParts.pop() ?? '';
newLines.splice(newCursorRow + 1, 0, ...remainingParts);
newLines.splice(
newCursorRow + parts.length - 1,
0,
lastPartOriginal + after,
);
newCursorRow = newCursorRow + parts.length - 1;
newCursorCol = cpLen(lastPartOriginal);
} else {
newLines[newCursorRow] = before + parts[0] + after;
newCursorCol = cpLen(before) + cpLen(parts[0]);
}
} else if (op.type === 'backspace') {
if (newCursorCol === 0 && newCursorRow === 0) continue;
if (newCursorCol > 0) {
const lineContent = currentLine(newCursorRow);
newLines[newCursorRow] =
cpSlice(lineContent, 0, newCursorCol - 1) +
cpSlice(lineContent, newCursorCol);
newCursorCol--;
} else if (newCursorRow > 0) {
const prevLineContent = currentLine(newCursorRow - 1);
const currentLineContentVal = currentLine(newCursorRow);
const newCol = cpLen(prevLineContent);
newLines[newCursorRow - 1] = prevLineContent + currentLineContentVal;
newLines.splice(newCursorRow, 1);
newCursorRow--;
newCursorCol = newCol;
}
}
}
setLines(newLines);
setCursorRow(newCursorRow);
setCursorCol(newCursorCol);
setPreferredCol(null);
// Clear the queue after processing
setOpQueue((prev) => prev.slice(opQueue.length));
}, [opQueue, lines, cursorRow, cursorCol, pushUndo, setPreferredCol]);
const insert = useCallback(
(ch: string): void => {
if (/[\n\r]/.test(ch)) {
insertStr(ch);
return;
}
dbg('insert', { ch, beforeCursor: [cursorRow, cursorCol] });
ch = stripUnsafeCharacters(ch);
@@ -694,7 +657,7 @@ export function useTextBuffer({
}
applyOperations([{ type: 'insert', payload: ch }]);
},
[applyOperations, cursorRow, cursorCol, isValidPath, insertStr],
[applyOperations, cursorRow, cursorCol, isValidPath],
);
const newline = useCallback((): void => {
@@ -1397,8 +1360,9 @@ export function useTextBuffer({
}, [selectionAnchor, cursorRow, cursorCol, currentLine, setClipboard]),
paste: useCallback(() => {
if (clipboard === null) return false;
return insertStr(clipboard);
}, [clipboard, insertStr]),
applyOperations([{ type: 'insert', payload: clipboard }]);
return true;
}, [clipboard, applyOperations]),
startSelection: useCallback(
() => setSelectionAnchor([cursorRow, cursorCol]),
[cursorRow, cursorCol, setSelectionAnchor],

View File

@@ -135,7 +135,8 @@ export function useCompletion(
(cmd) => cmd.name === commandName || cmd.altName === commandName,
);
if (command && command.completion) {
// Continue to show command help until user types past command name.
if (command && command.completion && parts.length > 1) {
const fetchAndSetSuggestions = async () => {
setIsLoadingSuggestions(true);
if (command.completion) {