Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/cli/src/zed-integration/acpResume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ describe('GeminiAgent Session Resume', () => {
},
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Config>;
mockSettings = {
merged: {
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/zed-integration/commandHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { CommandHandler } from './commandHandler.js';
import { describe, it, expect } from 'vitest';

describe('CommandHandler', () => {
it('parses commands correctly', () => {
const handler = new CommandHandler();
// @ts-expect-error - testing private method
const parse = (query: string) => handler.parseSlashCommand(query);

const memShow = parse('/memory show');
expect(memShow.commandToExecute?.name).toBe('memory show');
expect(memShow.args).toBe('');

const memAdd = parse('/memory add hello world');
expect(memAdd.commandToExecute?.name).toBe('memory add');
expect(memAdd.args).toBe('hello world');

const extList = parse('/extensions list');
expect(extList.commandToExecute?.name).toBe('extensions list');

const init = parse('/init');
expect(init.commandToExecute?.name).toBe('init');
});
});
134 changes: 134 additions & 0 deletions packages/cli/src/zed-integration/commandHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import type { Command, CommandContext } from './commands/types.js';
import { CommandRegistry } from './commands/commandRegistry.js';
import { MemoryCommand } from './commands/memory.js';
import { ExtensionsCommand } from './commands/extensions.js';
import { InitCommand } from './commands/init.js';
import { RestoreCommand } from './commands/restore.js';

export class CommandHandler {
private registry: CommandRegistry;

constructor() {
this.registry = CommandHandler.createRegistry();
}

private static createRegistry(): CommandRegistry {
const registry = new CommandRegistry();
registry.register(new MemoryCommand());
registry.register(new ExtensionsCommand());
registry.register(new InitCommand());
registry.register(new RestoreCommand());
return registry;
}

getAvailableCommands(): Array<{ name: string; description: string }> {
return this.registry.getAllCommands().map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
}

/**
* Parses and executes a command string if it matches a registered command.
* Returns true if a command was handled, false otherwise.
*/
async handleCommand(
commandText: string,
context: CommandContext,
): Promise<boolean> {
const { commandToExecute, args } = this.parseSlashCommand(commandText);

if (commandToExecute) {
await this.runCommand(commandToExecute, args, context);
return true;
}

return false;
}

private async runCommand(
commandToExecute: Command,
args: string,
context: CommandContext,
): Promise<void> {
try {
const result = await commandToExecute.execute(
context,
args ? args.split(/\s+/) : [],
);

let messageContent = '';
if (typeof result.data === 'string') {
messageContent = result.data;
} else if (
typeof result.data === 'object' &&
result.data !== null &&
'content' in result.data
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
messageContent = (result.data as Record<string, any>)[
'content'
] as string;
} else {
messageContent = JSON.stringify(result.data, null, 2);
}

await context.sendMessage(messageContent);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
await context.sendMessage(`Error: ${errorMessage}`);
}
}

/**
* Parses a raw slash command string into its matching headless command and arguments.
* Mirrors `packages/cli/src/utils/commands.ts` logic.
*/
private parseSlashCommand(query: string): {
commandToExecute: Command | undefined;
args: string;
} {
const trimmed = query.trim();
const parts = trimmed.substring(1).trim().split(/\s+/);
const commandPath = parts.filter((p) => p);

let currentCommands = this.registry.getAllCommands();
let commandToExecute: Command | undefined;
let pathIndex = 0;

for (const part of commandPath) {
const foundCommand = currentCommands.find((cmd) => {
const expectedName = commandPath.slice(0, pathIndex + 1).join(' ');
return (
cmd.name === part ||
cmd.name === expectedName ||
cmd.aliases?.includes(part) ||
cmd.aliases?.includes(expectedName)
);
});

if (foundCommand) {
commandToExecute = foundCommand;
pathIndex++;
if (foundCommand.subCommands) {
currentCommands = foundCommand.subCommands;
} else {
break;
}
} else {
break;
}
}

const args = parts.slice(pathIndex).join(' ');

return { commandToExecute, args };
}
}
33 changes: 33 additions & 0 deletions packages/cli/src/zed-integration/commands/commandRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { debugLogger } from '@google/gemini-cli-core';
import type { Command } from './types.js';

export class CommandRegistry {
private readonly commands = new Map<string, Command>();

register(command: Command) {
if (this.commands.has(command.name)) {
debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
return;
}

this.commands.set(command.name, command);

for (const subCommand of command.subCommands ?? []) {
this.register(subCommand);
}
}

get(commandName: string): Command | undefined {
return this.commands.get(commandName);
}

getAllCommands(): Command[] {
return [...this.commands.values()];
}
}
Loading
Loading