Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/cli/src/config/keyBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export enum Command {
CLEAR_SCREEN = 'app.clearScreen',
RESTART_APP = 'app.restart',
SUSPEND_APP = 'app.suspend',
OPEN_DIRECTORY = 'app.openDirectory',
}

/**
Expand Down Expand Up @@ -297,6 +298,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
[Command.RESTART_APP]: [{ key: 'r' }],
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
[Command.OPEN_DIRECTORY]: [{ key: 'o', alt: true }],
};

interface CommandCategory {
Expand Down Expand Up @@ -417,6 +419,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.CLEAR_SCREEN,
Command.RESTART_APP,
Command.SUSPEND_APP,
Command.OPEN_DIRECTORY,
],
},
];
Expand Down Expand Up @@ -525,4 +528,5 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.RESTART_APP]: 'Restart the application.',
[Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.',
[Command.OPEN_DIRECTORY]: 'Open the directory containing the file.',
};
32 changes: 14 additions & 18 deletions packages/cli/src/ui/commands/memoryCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,37 +421,33 @@ describe('memoryCommand', () => {
});
});

it('should display a message if no GEMINI.md files are found', async () => {
it('should return a custom dialog component with empty file paths if no GEMINI.md files are found', async () => {
if (!listCommand.action) throw new Error('Command has no action');

mockGetGeminiMdfilePaths.mockReturnValue([]);

await listCommand.action(mockContext, '');
const result = await listCommand.action(mockContext, '');

expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'No GEMINI.md files in use.',
},
expect.any(Number),
);
expect(result).toMatchObject({
type: 'custom_dialog',
component: expect.anything(),
});
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
});

it('should display the file count and paths if they exist', async () => {
it('should return a custom dialog component with file paths if they exist', async () => {
if (!listCommand.action) throw new Error('Command has no action');

const filePaths = ['/path/one/GEMINI.md', '/path/two/GEMINI.md'];
mockGetGeminiMdfilePaths.mockReturnValue(filePaths);

await listCommand.action(mockContext, '');
const result = await listCommand.action(mockContext, '');

expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `There are 2 GEMINI.md file(s) in use:\n\n${filePaths.join('\n')}`,
},
expect.any(Number),
);
expect(result).toMatchObject({
type: 'custom_dialog',
component: expect.anything(),
});
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
});
});
});
31 changes: 16 additions & 15 deletions packages/cli/src/ui/commands/memoryCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {
addMemory,
listMemoryFiles,
refreshMemory,
showMemory,
} from '@google/gemini-cli-core';
import { addMemory, refreshMemory, showMemory } from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import React from 'react';
import { MemoryList } from '../components/MemoryList.js';

export const memoryCommand: SlashCommand = {
name: 'memory',
Expand Down Expand Up @@ -107,18 +104,22 @@ export const memoryCommand: SlashCommand = {
description: 'Lists the paths of the GEMINI.md files in use',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
action: async (context): Promise<SlashCommandActionReturn | void> => {
const config = context.services.config;
if (!config) return;
const result = listMemoryFiles(config);

context.ui.addItem(
{
type: MessageType.INFO,
text: result.content,
},
Date.now(),
);
return {
type: 'custom_dialog',
component: React.createElement(MemoryList, {
filePaths: config.getGeminiMdFilePaths() || [],
onClose: () => context.ui.removeComponent(),
onError: (message: string) =>
context.ui.addItem(
{ type: MessageType.ERROR, text: message },
Date.now(),
),
}),
};
},
},
],
Expand Down
156 changes: 156 additions & 0 deletions packages/cli/src/ui/components/MemoryList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import type React from 'react';
import { useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import open from 'open';
import path from 'node:path';

interface MemoryListProps {
filePaths: string[];
onClose: () => void;
onError: (message: string) => void;
}

export const MemoryList: React.FC<MemoryListProps> = ({
filePaths,
onClose,
onError,
}) => {
const { terminalWidth, terminalHeight } = useUIState();
const [highlightedIndex, setHighlightedIndex] = useState<number>(0);

const items = useMemo(
() =>
filePaths.map((filePath, index) => ({
key: `file-${index}`,
value: filePath,
index,
})),
[filePaths],
);

useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
onClose();
return true;
}

if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
const selectedFile = filePaths[highlightedIndex];
if (selectedFile) {
// Using open package so we do not run into command injection issues
open(selectedFile).catch((e: Error) => {
onError(`Failed to open file: ${e.message}`);
});
}
return true;
}

if (keyMatchers[Command.OPEN_DIRECTORY](key)) {
const selectedFile = filePaths[highlightedIndex];
if (selectedFile) {
open(path.dirname(selectedFile)).catch((e: Error) => {
onError(`Failed to open directory: ${e.message}`);
});
}
return true;
}

return false;
},
{ isActive: true },
);

const DIALOG_PADDING = 2;
const HEADER_HEIGHT = 2;
const CONTROLS_HEIGHT = 2;

const listHeight = Math.max(
5,
terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2,
);

const maxItemsToShow = Math.max(1, Math.floor(listHeight));

if (filePaths.length === 0) {
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
padding={1}
width={terminalWidth}
>
<Text>No GEMINI.md files found.</Text>
</Box>
);
}

return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
width={terminalWidth}
paddingX={1}
paddingY={1}
>
<Box marginBottom={1}>
<Text bold>{'> '}Memory Files</Text>
</Box>

<Box flexDirection="column" flexGrow={1}>
<BaseSelectionList
items={items}
initialIndex={highlightedIndex}
isFocused={true}
showNumbers={true}
wrapAround={false}
onSelect={() => {
// Do nothing on enter for now, or just close
onClose();
}}
onHighlight={(item: string) => {
const index = filePaths.indexOf(item);
if (index !== -1) {
setHighlightedIndex(index);
}
}}
maxItemsToShow={maxItemsToShow}
renderItem={(itemWrapper, { isSelected }) => {
const filePath = itemWrapper.value;
const basename = path.basename(filePath);
const dir = path.dirname(filePath);

return (
<Box flexDirection="column">
<Text
color={isSelected ? theme.status.success : theme.text.primary}
>
{basename}
</Text>
<Text color={theme.text.secondary}>{dir}</Text>
</Box>
);
}}
/>
</Box>

<Box marginTop={1}>
<Text color={theme.text.secondary}>
(Use Esc to close, Ctrl+X to open in editor, Alt+O to open folder)
</Text>
</Box>
</Box>
);
};