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
25 changes: 23 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import "./styles/app.css";
function App() {
const commandRegistry = useMemo(() => createBasicCommandRegistry(), []);
const [mode, setMode] = useState<"panel" | "inline">("panel");
const [includeHelpCommand, setIncludeHelpCommand] = useState(true);
const ModeToggle = () => (
<div className="flex justify-center mb-6">
<div className="inline-flex rounded-full border border-gray-300 bg-gray-100 p-1 text-sm font-medium">
Expand Down Expand Up @@ -38,10 +39,24 @@ function App() {
</div>
);

const ToggleHelpButton = () => (
<button
type="button"
className="mt-4 px-3 py-2 rounded bg-gray-200 hover:bg-gray-300 text-gray-700 transition"
onClick={() => setIncludeHelpCommand((prev) => !prev)}
data-testid="toggle-help-command"
>
{includeHelpCommand ? 'Disable help command' : 'Enable help command'}
</button>
);

return (
<div className="min-h-screen bg-gray-800 flex items-center justify-center p-8">
<div className="w-full max-w-3xl bg-white rounded-lg shadow-lg p-6">
<ModeToggle />
<div className="flex justify-center">
<ToggleHelpButton />
</div>
<h1 className="text-xl font-semibold text-gray-800 text-center mb-4">
Citadel Demo
</h1>
Expand All @@ -51,7 +66,10 @@ function App() {
className="h-[420px] border border-gray-200 rounded relative overflow-hidden bg-gray-900"
data-testid="citadel-inline-demo"
>
<Citadel config={{ displayMode: "inline" }} commandRegistry={commandRegistry} />
<Citadel
config={{ displayMode: "inline", includeHelpCommand }}
commandRegistry={commandRegistry}
/>
</div>
</>
) : (
Expand All @@ -62,7 +80,10 @@ function App() {
</p>
<p className="text-sm text-gray-500">Press Escape to hide.</p>
</div>
<Citadel commandRegistry={commandRegistry} />
<Citadel
commandRegistry={commandRegistry}
config={{ includeHelpCommand }}
/>
</>
)}
</div>
Expand Down
24 changes: 17 additions & 7 deletions src/components/Citadel/components/AvailableCommands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,30 @@ export const AvailableCommands: React.FC = () => {
const config = useCitadelConfig();
const segmentStack = useSegmentStack();

const containerClasses = "h-12 mt-2 border-t border-gray-700 px-4";
const contentClasses = "text-gray-300 pt-2";
const containerClasses = "mt-2 border-t border-gray-700 px-4 py-2";
const contentClasses = "text-gray-300";

const nextCommandSegments = commands.getCompletions(segmentStack.path());
Logger.debug("[AvailableCommands] nextCommandSegments: ", nextCommandSegments);

const sortedCommands = React.useMemo(() => {
if (config.includeHelpCommand) {
const nonHelpCommands = nextCommandSegments.filter(segment => segment.name !== 'help');
const helpCommand = nextCommandSegments.find(segment => segment.name === 'help');
return [...nonHelpCommands, ...(helpCommand ? [helpCommand] : [])];
const segments = [...nextCommandSegments];
const isHelpSegment = (segment: typeof segments[number]) =>
segment.name.toLowerCase() === 'help';

const nonHelpSegments = segments.filter(segment => !isHelpSegment(segment));
const sortedNonHelpSegments = nonHelpSegments.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
);

if (!config.includeHelpCommand) {
return sortedNonHelpSegments;
}

return nextCommandSegments;
const helpSegment = segments.find(isHelpSegment);
return helpSegment
? [...sortedNonHelpSegments, helpSegment]
: sortedNonHelpSegments;
}, [nextCommandSegments, config.includeHelpCommand]);

const nextSegmentIsArgument = nextCommandSegments.some(seg => seg.type === 'argument');
Expand Down
100 changes: 85 additions & 15 deletions src/components/Citadel/components/__tests__/AvailableCommands.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { AvailableCommands } from '../AvailableCommands';
import { CitadelConfigProvider } from '../../config/CitadelConfigContext';
import { CommandRegistry } from '../../types/command-registry';
Expand All @@ -9,7 +9,7 @@ describe('AvailableCommands', () => {
let cmdRegistry: CommandRegistry;

const defaultConfig = {
includeHelpCommand: false,
includeHelpCommand: true,
resetStateOnHide: true,
showCitadelKey: '.'
};
Expand All @@ -29,30 +29,35 @@ describe('AvailableCommands', () => {
cmdRegistry = new CommandRegistry();
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('command display', () => {
beforeEach(() => {
cmdRegistry.addCommand(
[createMockSegment('word', 'test')],
'Test command'
);
cmdRegistry.addCommand(
[createMockSegment('word', 'help')],
'Help command'
);
});

it('renders available commands when not entering arguments', () => {
it('renders available commands when not entering arguments', async () => {
const { container } = renderWithConfig();
expect(container.textContent).toContain('test');
expect(container.textContent).toContain('help');
await waitFor(() => {
const content = container.textContent || '';
expect(content).toContain('test');
expect(content).toContain('help');
});
});

it('handles help command placement based on config', () => {
it('handles help command placement based on config', async () => {
const { container } = renderWithConfig();
const content = container.textContent || '';
const helpIndex = content.indexOf('help');
const testIndex = content.indexOf('test');
expect(helpIndex).toBeGreaterThan(testIndex);
await waitFor(() => {
const content = container.textContent || '';
const helpIndex = content.indexOf('help');
const testIndex = content.indexOf('test');
expect(helpIndex).toBeGreaterThan(testIndex);
});
});

it.skip('renders without help command when disabled in config', () => {
Expand Down Expand Up @@ -109,4 +114,69 @@ describe('AvailableCommands', () => {
expect(elements[0].textContent).toBeTruthy();
});
});

describe('command sorting', () => {
it('sorts commands alphabetically and keeps help last when included', async () => {
const segments = ['zeta', 'alpha', 'eta', 'help'].map((name) =>
createMockSegment('word', name)
);

vi.spyOn(cmdRegistry, 'getCompletions').mockReturnValue(segments);
vi.spyOn(cmdRegistry, 'getCompletions_s').mockReturnValue(
segments.map((segment) => segment.name)
);
vi.spyOn(cmdRegistry, 'commandExistsForPath').mockReturnValue(true);
vi.spyOn(cmdRegistry, 'addCommand').mockImplementation(() => {});

const { container } = renderWithConfig({
...defaultConfig,
includeHelpCommand: true
});

await waitFor(() => {
const commandChips = Array.from(container.querySelectorAll('.font-mono'));
const commandNames = commandChips.map((node) => node.textContent?.trim());
expect(commandNames).toEqual(['alpha', 'eta', 'zeta', 'help']);
});
});

it('omits help and sorts alphabetically when help is excluded', async () => {
const segments = ['gamma', 'beta'].map((name) =>
createMockSegment('word', name)
);
vi.spyOn(cmdRegistry, 'getCompletions').mockReturnValue(segments);
vi.spyOn(cmdRegistry, 'getCompletions_s').mockReturnValue(
segments.map((segment) => segment.name)
);

const { container } = renderWithConfig({
...defaultConfig,
includeHelpCommand: false
});

await waitFor(() => {
const commandChips = Array.from(container.querySelectorAll('.font-mono'));
const commandNames = commandChips.map((node) => node.textContent?.trim());
expect(commandNames).toEqual(['beta', 'gamma']);
});
});
});

describe('layout', () => {
it('does not enforce a fixed height when multiple rows are needed', async () => {
['cowsay', 'error', 'image', 'localstorage', 'thing', 'fnord', 'user'].forEach((name) => {
cmdRegistry.addCommand([createMockSegment('word', name)], `${name} command`);
});

const { getByTestId } = renderWithConfig({
...defaultConfig,
includeHelpCommand: true
});

await waitFor(() => {
const container = getByTestId('available-commands');
expect(container.className.split(' ')).not.toContain('h-12');
});
});
});
});
9 changes: 8 additions & 1 deletion src/components/Citadel/config/CitadelConfigContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ export const CitadelConfigProvider: React.FC<{

// Add help command if enabled
useEffect(() => {
if (commands && mergedConfig.includeHelpCommand) {
if (!commands) {
return;
}

if (mergedConfig.includeHelpCommand) {
if (!commands.commandExistsForPath(['help'])) {
const helpHandler = createHelpHandler(commands);
commands.addCommand(
Expand All @@ -66,7 +70,10 @@ export const CitadelConfigProvider: React.FC<{
helpHandler
);
}
return;
}

commands.removeCommand(['help']);
}, [commands, mergedConfig.includeHelpCommand]);

const contextValue: CitadelContextValue = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, act } from '@testing-library/react';
import { render, act, waitFor } from '@testing-library/react';
import { CitadelConfigProvider } from '../CitadelConfigContext';
import { useCitadelCommands, useCitadelConfig, useCitadelStorage } from '../hooks';
import { CommandRegistry } from '../../types/command-registry';
Expand Down Expand Up @@ -173,6 +173,34 @@ describe('CitadelConfigContext', () => {
);
expect(commands).toHaveLength(1);
});

it('should remove help command when includeHelpCommand is disabled', async () => {
const testCmdRegistry = new CommandRegistry();

const { rerender } = render(
<CitadelConfigProvider
config={{ includeHelpCommand: true }}
commandRegistry={testCmdRegistry}
>
<div>Test</div>
</CitadelConfigProvider>
);

expect(testCmdRegistry.commands.some(cmd => cmd.segments[0].name === 'help')).toBe(true);

rerender(
<CitadelConfigProvider
config={{ includeHelpCommand: false }}
commandRegistry={testCmdRegistry}
>
<div>Test</div>
</CitadelConfigProvider>
);

await waitFor(() => {
expect(testCmdRegistry.commands.some(cmd => cmd.segments[0].name === 'help')).toBe(false);
});
});
});

describe('storage initialization', () => {
Expand Down
18 changes: 18 additions & 0 deletions src/components/Citadel/types/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,24 @@ export class CommandRegistry {
this._commands.push(newCommandNode);
}

/**
* Removes a command that exactly matches the provided path.
*
* @param path The command path to remove.
* @returns True if a command was removed; otherwise false.
*/
removeCommand(path: string[]): boolean {
const targetPath = path.join(' ');
const index = this._commands.findIndex(command => command.fullPath.join(' ') === targetPath);

if (index === -1) {
return false;
}

this._commands.splice(index, 1);
return true;
}

/**
* Retrieves a command from the registry for the given path.
*
Expand Down
20 changes: 0 additions & 20 deletions src/examples/basicCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,25 +226,5 @@ export function createBasicCommandRegistry(): CommandRegistry {
},
)

// Command to dynamically add new commands
cmdRegistry.addCommand(
[
{ type: 'word', name: 'command' },
{ type: 'word', name: 'add' },
{ type: 'argument', name: 'name', description: 'Name of the command to add' },
],
'Dynamically add a new command',
async (args: string[]) => {
const newCommandName = args[0]
cmdRegistry.addCommand(
[{ type: 'word', name: newCommandName }],
`Dynamically added command "${newCommandName}"`,
async () =>
new TextCommandResult(`Executed dynamic command "${newCommandName}"`),
)
return new TextCommandResult(`Successfully added command "${newCommandName}"`)
},
)

return cmdRegistry
}
Loading