Skip to content

Commit 85f1edf

Browse files
committed
feat(ui): add source indicators to slash commands
1 parent 1fc8f30 commit 85f1edf

8 files changed

Lines changed: 295 additions & 8 deletions

File tree

packages/cli/src/services/FileCommandLoader.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1402,4 +1402,87 @@ describe('FileCommandLoader', () => {
14021402
expect(commands[0].description).toBe('d'.repeat(97) + '...');
14031403
});
14041404
});
1405+
1406+
describe('CommandSource Assignment', () => {
1407+
it('assigns CommandSource.USER to user commands', async () => {
1408+
const userCommandsDir = Storage.getUserCommandsDir();
1409+
mock({
1410+
[userCommandsDir]: {
1411+
'user-cmd.toml': 'prompt = "User prompt"',
1412+
},
1413+
});
1414+
1415+
const loader = new FileCommandLoader(null);
1416+
const commands = await loader.loadCommands(signal);
1417+
1418+
expect(commands).toHaveLength(1);
1419+
expect(commands[0].source).toBe('user');
1420+
});
1421+
1422+
it('assigns CommandSource.PROJECT to project commands', async () => {
1423+
const projectCommandsDir = new Storage(
1424+
process.cwd(),
1425+
).getProjectCommandsDir();
1426+
mock({
1427+
[projectCommandsDir]: {
1428+
'project-cmd.toml': 'prompt = "Project prompt"',
1429+
},
1430+
});
1431+
1432+
const mockConfig = {
1433+
getProjectRoot: vi.fn(() => process.cwd()),
1434+
getExtensions: vi.fn(() => []),
1435+
getFolderTrust: vi.fn(() => false),
1436+
isTrustedFolder: vi.fn(() => false),
1437+
} as unknown as Config;
1438+
const loader = new FileCommandLoader(mockConfig);
1439+
const commands = await loader.loadCommands(signal);
1440+
1441+
// 0 is user (empty), 1 is project
1442+
const projectCmd = commands.find((c) => c.name === 'project-cmd');
1443+
expect(projectCmd).toBeDefined();
1444+
expect(projectCmd?.source).toBe('project');
1445+
});
1446+
1447+
it('assigns CommandSource.EXTENSION to extension commands', async () => {
1448+
const extensionDir = path.join(
1449+
process.cwd(),
1450+
GEMINI_DIR,
1451+
'extensions',
1452+
'test-ext',
1453+
);
1454+
1455+
mock({
1456+
[extensionDir]: {
1457+
'gemini-extension.json': JSON.stringify({
1458+
name: 'test-ext',
1459+
version: '1.0.0',
1460+
}),
1461+
commands: {
1462+
'ext-cmd.toml': 'prompt = "Extension prompt"',
1463+
},
1464+
},
1465+
});
1466+
1467+
const mockConfig = {
1468+
getProjectRoot: vi.fn(() => process.cwd()),
1469+
getExtensions: vi.fn(() => [
1470+
{
1471+
name: 'test-ext',
1472+
version: '1.0.0',
1473+
isActive: true,
1474+
path: extensionDir,
1475+
},
1476+
]),
1477+
getFolderTrust: vi.fn(() => false),
1478+
isTrustedFolder: vi.fn(() => false),
1479+
} as unknown as Config;
1480+
const loader = new FileCommandLoader(mockConfig);
1481+
const commands = await loader.loadCommands(signal);
1482+
1483+
const extCmd = commands.find((c) => c.name === 'ext-cmd');
1484+
expect(extCmd).toBeDefined();
1485+
expect(extCmd?.source).toBe('extension');
1486+
});
1487+
});
14051488
});

packages/cli/src/services/FileCommandLoader.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type {
1717
SlashCommand,
1818
SlashCommandActionReturn,
1919
} from '../ui/commands/types.js';
20-
import { CommandKind } from '../ui/commands/types.js';
20+
import { CommandKind, CommandSource } from '../ui/commands/types.js';
2121
import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js';
2222
import type {
2323
IPromptProcessor,
@@ -39,6 +39,7 @@ interface CommandDirectory {
3939
path: string;
4040
extensionName?: string;
4141
extensionId?: string;
42+
source: CommandSource;
4243
}
4344

4445
/**
@@ -113,6 +114,7 @@ export class FileCommandLoader implements ICommandLoader {
113114
dirInfo.path,
114115
dirInfo.extensionName,
115116
dirInfo.extensionId,
117+
dirInfo.source,
116118
),
117119
);
118120

@@ -151,10 +153,16 @@ export class FileCommandLoader implements ICommandLoader {
151153
const storage = this.config?.storage ?? new Storage(this.projectRoot);
152154

153155
// 1. User commands
154-
dirs.push({ path: Storage.getUserCommandsDir() });
156+
dirs.push({
157+
path: Storage.getUserCommandsDir(),
158+
source: CommandSource.USER,
159+
});
155160

156161
// 2. Project commands (override user commands)
157-
dirs.push({ path: storage.getProjectCommandsDir() });
162+
dirs.push({
163+
path: storage.getProjectCommandsDir(),
164+
source: CommandSource.PROJECT,
165+
});
158166

159167
// 3. Extension commands (processed last to detect all conflicts)
160168
if (this.config) {
@@ -167,6 +175,7 @@ export class FileCommandLoader implements ICommandLoader {
167175
path: path.join(ext.path, 'commands'),
168176
extensionName: ext.name,
169177
extensionId: ext.id,
178+
source: CommandSource.EXTENSION,
170179
}));
171180

172181
dirs.push(...extensionCommandDirs);
@@ -185,8 +194,9 @@ export class FileCommandLoader implements ICommandLoader {
185194
private async parseAndAdaptFile(
186195
filePath: string,
187196
baseDir: string,
188-
extensionName?: string,
189-
extensionId?: string,
197+
extensionName: string | undefined,
198+
extensionId: string | undefined,
199+
source: CommandSource,
190200
): Promise<SlashCommand | null> {
191201
let fileContent: string;
192202
try {
@@ -289,6 +299,7 @@ export class FileCommandLoader implements ICommandLoader {
289299
kind: CommandKind.FILE,
290300
extensionName,
291301
extensionId,
302+
source,
292303
action: async (
293304
context: CommandContext,
294305
_args: string,

packages/cli/src/ui/commands/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,12 @@ export enum CommandKind {
182182
AGENT = 'agent',
183183
}
184184

185+
export enum CommandSource {
186+
USER = 'user',
187+
PROJECT = 'project',
188+
EXTENSION = 'extension',
189+
}
190+
185191
// The standardized contract for any command in the system.
186192
export interface SlashCommand {
187193
name: string;
@@ -190,6 +196,7 @@ export interface SlashCommand {
190196
hidden?: boolean;
191197

192198
kind: CommandKind;
199+
source?: CommandSource;
193200

194201
/**
195202
* Controls whether the command auto-executes when selected with Enter.

packages/cli/src/ui/components/SuggestionsDisplay.test.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { render } from '../../test-utils/render.js';
88
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
99
import { describe, it, expect } from 'vitest';
10-
import { CommandKind } from '../commands/types.js';
10+
import { CommandKind, CommandSource } from '../commands/types.js';
1111

1212
describe('SuggestionsDisplay', () => {
1313
const mockSuggestions = [
@@ -121,4 +121,40 @@ describe('SuggestionsDisplay', () => {
121121
);
122122
expect(lastFrame()).toMatchSnapshot();
123123
});
124+
125+
it('renders source tags for file commands', () => {
126+
const sourcedSuggestions = [
127+
{
128+
label: 'User Cmd',
129+
value: 'user-cmd',
130+
commandKind: CommandKind.FILE,
131+
source: CommandSource.USER,
132+
},
133+
{
134+
label: 'Project Cmd',
135+
value: 'project-cmd',
136+
commandKind: CommandKind.FILE,
137+
source: CommandSource.PROJECT,
138+
},
139+
{
140+
label: 'Ext Cmd',
141+
value: 'ext-cmd',
142+
commandKind: CommandKind.FILE,
143+
source: CommandSource.EXTENSION,
144+
},
145+
];
146+
147+
const { lastFrame } = render(
148+
<SuggestionsDisplay
149+
suggestions={sourcedSuggestions}
150+
activeIndex={0}
151+
isLoading={false}
152+
width={80}
153+
scrollOffset={0}
154+
userInput=""
155+
mode="reverse"
156+
/>,
157+
);
158+
expect(lastFrame()).toMatchSnapshot();
159+
});
124160
});

packages/cli/src/ui/components/SuggestionsDisplay.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { Box, Text } from 'ink';
88
import { theme } from '../semantic-colors.js';
99
import { ExpandableText, MAX_WIDTH } from './shared/ExpandableText.js';
10-
import { CommandKind } from '../commands/types.js';
10+
import { CommandKind, CommandSource } from '../commands/types.js';
1111
import { Colors } from '../colors.js';
1212
import { sanitizeForDisplay } from '../utils/textUtils.js';
1313

@@ -17,6 +17,7 @@ export interface Suggestion {
1717
description?: string;
1818
matchedIndex?: number;
1919
commandKind?: CommandKind;
20+
source?: CommandSource;
2021
}
2122
interface SuggestionsDisplayProps {
2223
suggestions: Suggestion[];
@@ -67,8 +68,16 @@ export function SuggestionsDisplay({
6768
[CommandKind.AGENT]: ' [Agent]',
6869
};
6970

71+
const COMMAND_SOURCE_SUFFIX: Partial<Record<CommandSource, string>> = {
72+
[CommandSource.USER]: ' [USER]',
73+
[CommandSource.PROJECT]: ' [PROJECT]',
74+
[CommandSource.EXTENSION]: ' [EXT]',
75+
};
76+
7077
const getFullLabel = (s: Suggestion) =>
71-
s.label + (s.commandKind ? (COMMAND_KIND_SUFFIX[s.commandKind] ?? '') : '');
78+
s.label +
79+
(s.commandKind ? (COMMAND_KIND_SUFFIX[s.commandKind] ?? '') : '') +
80+
(s.source ? (COMMAND_SOURCE_SUFFIX[s.source] ?? '') : '');
7281

7382
const maxLabelLength = Math.max(
7483
...suggestions.map((s) => getFullLabel(s).length),
@@ -111,6 +120,12 @@ export function SuggestionsDisplay({
111120
{COMMAND_KIND_SUFFIX[suggestion.commandKind]}
112121
</Text>
113122
)}
123+
{suggestion.source &&
124+
COMMAND_SOURCE_SUFFIX[suggestion.source] && (
125+
<Text color={textColor}>
126+
{COMMAND_SOURCE_SUFFIX[suggestion.source]}
127+
</Text>
128+
)}
114129
</Box>
115130
</Box>
116131

packages/cli/src/ui/components/__snapshots__/SuggestionsDisplay.test.tsx.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ exports[`SuggestionsDisplay > renders MCP tag for MCP prompts 1`] = `" mcp-tool
2424

2525
exports[`SuggestionsDisplay > renders loading state 1`] = `" Loading suggestions..."`;
2626

27+
exports[`SuggestionsDisplay > renders source tags for file commands 1`] = `
28+
" user-cmd [USER]
29+
project-cmd [PROJECT]
30+
ext-cmd [EXT]"
31+
`;
32+
2733
exports[`SuggestionsDisplay > renders suggestions list 1`] = `
2834
" command1 Description 1
2935
command2 Description 2

0 commit comments

Comments
 (0)