Skip to content
Closed
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
84 changes: 80 additions & 4 deletions src/__tests__/unit/cli-v2/control-plane-session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,75 @@ describe('ControlPlaneSessionStore', () => {
store.dispose();
});

it('handles /help without sending an agent prompt', async () => {
const fixture = createClientFixture();
const store = new ControlPlaneSessionStore({ client: fixture.client });
await store.start();

await store.submitPrompt('/help');

expect(fixture.calls.sessionSendPromptAsyncMutate).not.toHaveBeenCalled();
expect(store.getSnapshot()).toMatchObject({
error: undefined,
latestUpdate: {
label: 'CLI v2 commands',
tone: 'info',
},
});
expect(store.getSnapshot().latestUpdate?.detail).toContain('/new [name]');
store.dispose();
});

it('handles unknown slash commands without sending an agent prompt', async () => {
const fixture = createClientFixture();
const store = new ControlPlaneSessionStore({ client: fixture.client });
await store.start();

await store.submitPrompt('/whatever');

expect(fixture.calls.sessionSendPromptAsyncMutate).not.toHaveBeenCalled();
expect(store.getSnapshot()).toMatchObject({
error: 'Unknown cli-v2 slash command: /whatever. Use /help to inspect supported commands.',
});
store.dispose();
});

it('creates and selects a new session through the control-plane API for /new', async () => {
const fixture = createClientFixture();
const store = new ControlPlaneSessionStore({ client: fixture.client });
await store.start();
fixture.calls.sessionCreateMutate.mockResolvedValueOnce({
id: 'session-2',
name: 'Slash command slice',
workspaceId: 'workspace-1',
messageCount: 0,
turnCount: 0,
});
fixture.calls.sessionQuery.mockResolvedValueOnce(createSessionDetail('session-2', 'Slash command slice'));

await store.submitPrompt('/new Slash command slice');

expect(fixture.calls.sessionSendPromptAsyncMutate).not.toHaveBeenCalled();
expect(fixture.calls.sessionCreateMutate).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
name: 'Slash command slice',
model: undefined,
});
expect(fixture.calls.sessionQuery).toHaveBeenLastCalledWith({
id: 'session-2',
workspaceId: 'workspace-1',
});
expect(store.getSnapshot()).toMatchObject({
activeSessionId: 'session-2',
latestUpdate: {
label: 'Created new session',
detail: 'Slash command slice',
tone: 'success',
},
});
store.dispose();
});

it('does not finalize a submitted prompt before the send mutation resolves', async () => {
vi.useFakeTimers();
try {
Expand Down Expand Up @@ -427,6 +496,13 @@ function createClientFixture() {
summary: 'Done.',
})),
sessionSendPromptAsyncMutate: vi.fn(async () => createAcceptedResult()),
sessionCreateMutate: vi.fn(async () => ({
id: 'session-2',
name: 'Session 2',
workspaceId: 'workspace-1',
messageCount: 0,
turnCount: 0,
})),
sessionCancelMutate: vi.fn(async () => ({ cancelled: false })),
sessionResolveApprovalMutate: vi.fn(async () => ({ resolved: true })),
};
Expand All @@ -440,7 +516,7 @@ function createClientFixture() {
return { unsubscribe: vi.fn() };
}),
},
sessionCreate: { mutate: vi.fn() },
sessionCreate: { mutate: calls.sessionCreateMutate },
session: { query: calls.sessionQuery },
sessionEvents: {
subscribe: vi.fn((_input, options) => {
Expand Down Expand Up @@ -480,10 +556,10 @@ function createDeferred<T>() {
return { promise, resolve, reject };
}

function createSessionDetail(): NonNullable<ControlPlaneSessionDetail> {
function createSessionDetail(id = 'session-1', name = 'Session 1'): NonNullable<ControlPlaneSessionDetail> {
return {
id: 'session-1',
name: 'Session 1',
id,
name,
workspaceId: 'workspace-1',
messageCount: 1,
turnCount: 0,
Expand Down
178 changes: 178 additions & 0 deletions src/__tests__/unit/cli-v2/terminal-slash-command-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { describe, expect, it, vi } from 'vitest';
import type { ControlPlaneSessionView } from '../../../client-shared/api/types.js';
import {
TerminalSlashCommandService,
type TerminalSlashCommandContext,
} from '../../../cli-v2/services/commands/terminal-slash-command-service.js';
import { TerminalSlashCommandParser } from '../../../cli-v2/services/commands/terminal-slash-command-parser.js';
import { TerminalSlashCommandRegistry } from '../../../cli-v2/services/commands/terminal-slash-command-registry.js';
import type { TerminalSlashCommandModule } from '../../../cli-v2/services/commands/types.js';

describe('TerminalSlashCommandService', () => {
it('parses terminal slash commands without treating absolute paths as commands', () => {
expect(TerminalSlashCommandParser.parse(' /new Slice ')).toEqual({
raw: '/new Slice',
root: '/new',
rest: 'Slice',
});
expect(TerminalSlashCommandParser.parse('/Users/me/screenshot.png')).toBeUndefined();
});

it('publishes module-owned hints through the registry', async () => {
const registry = new TerminalSlashCommandRegistry([createTestCommandModule()]);

expect(registry.hints()).toEqual([
{ command: '/test', description: 'run test command' },
]);
await expect(registry.execute(createContext(), '/test')).resolves.toEqual({
handled: true,
status: {
label: 'Test command',
tone: 'info',
},
});
});

it('rejects duplicate command module ids', () => {
expect(() => new TerminalSlashCommandRegistry([
createTestCommandModule(),
createTestCommandModule(),
])).toThrow('Duplicate cli-v2 slash command module id: test');
});

it('does not handle regular prompts', async () => {
const service = new TerminalSlashCommandService();

await expect(service.execute('Build the next slice', createContext())).resolves.toEqual({ handled: false });
});

it('lists supported commands for /help', async () => {
const service = new TerminalSlashCommandService();

const result = await service.execute('/help', createContext());

expect(result).toMatchObject({
handled: true,
status: {
label: 'CLI v2 commands',
tone: 'info',
},
});
expect(result.handled && result.status?.detail).toContain('/new [name]');
});

it('returns a visible error for unknown slash commands', async () => {
const service = new TerminalSlashCommandService();

await expect(service.execute('/whatever', createContext())).resolves.toEqual({
handled: true,
error: 'Unknown cli-v2 slash command: /whatever. Use /help to inspect supported commands.',
});
});

it('creates and selects a new session through the command context', async () => {
const service = new TerminalSlashCommandService();
const context = createContext();

const result = await service.execute('/new Refactor slice', context);

expect(context.createSession).toHaveBeenCalledWith({ name: 'Refactor slice' });
expect(context.selectSession).toHaveBeenCalledWith('session-2');
expect(result).toEqual({
handled: true,
status: {
label: 'Created new session',
detail: 'Refactor slice',
tone: 'success',
},
});
});

it('blocks mutating commands while a run is active', async () => {
const service = new TerminalSlashCommandService();
const context = createContext({ isRunActive: true });

await expect(service.execute('/new', context)).resolves.toEqual({
handled: true,
error: 'Cannot create a new session while the current run is active.',
});
expect(context.createSession).not.toHaveBeenCalled();
});

it('refreshes and formats sessions for /sessions', async () => {
const service = new TerminalSlashCommandService();
const context = createContext();

const result = await service.execute('/sessions', context);

expect(context.refreshSessions).toHaveBeenCalled();
expect(result).toMatchObject({
handled: true,
status: {
label: 'Sessions refreshed',
detail: '* Session 1 (session-1)\nRefactor slice (session-2)',
tone: 'info',
},
});
});
});

function createTestCommandModule(): TerminalSlashCommandModule {
return {
id: 'test',
hints: [
{ command: '/test', description: 'run test command' },
],
commands: [
{
id: 'test.run',
syntax: '/test',
description: 'run test command',
match: TerminalSlashCommandParser.matchesExact('/test'),
execute: () => ({
handled: true,
status: {
label: 'Test command',
tone: 'info',
},
}),
},
],
};
}

function createContext(options: { isRunActive?: boolean } = {}): TerminalSlashCommandContext {
const sessions = createSessions();
return {
activeSessionId: 'session-1',
isRunActive: options.isRunActive ?? false,
refreshSessions: vi.fn(async () => sessions),
createSession: vi.fn(async (input) => ({
id: 'session-2',
name: input.name ?? 'New session',
workspaceId: 'workspace-1',
messageCount: 0,
turnCount: 0,
})),
selectSession: vi.fn(async () => undefined),
};
}

function createSessions(): ControlPlaneSessionView[] {
return [
{
id: 'session-1',
name: 'Session 1',
workspaceId: 'workspace-1',
messageCount: 1,
turnCount: 0,
},
{
id: 'session-2',
name: 'Refactor slice',
workspaceId: 'workspace-1',
messageCount: 0,
turnCount: 0,
},
];
}
15 changes: 14 additions & 1 deletion src/cli-v2/components/PromptInput.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Dispatch, SetStateAction } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text, useInput, useStdout } from 'ink';
import type { PromptActivityView } from '../services/activities/prompt-activity-service.js';

export function PromptInput({
Expand All @@ -19,6 +19,9 @@ export function PromptInput({
onChange: Dispatch<SetStateAction<string>>;
onSubmit: (value: string) => void;
}) {
const { stdout } = useStdout();
const separator = repeatSeparator((stdout.columns ?? 0) - 2);

useInput((input, key) => {
if (disabled) {
return;
Expand All @@ -45,11 +48,21 @@ export function PromptInput({

return (
<Box flexDirection="column" marginTop={1} marginBottom={1}>
<Box overflow="hidden">
<Text dimColor wrap="truncate-end">{separator}</Text>
</Box>
{activity ? <Text color={activity.color}>{activity.text}</Text> : null}
<Box>
<Text color="cyan">› </Text>
<Text>{value || <Text dimColor>{placeholder}</Text>}</Text>
</Box>
<Box overflow="hidden">
<Text dimColor wrap="truncate-end">{separator}</Text>
</Box>
</Box>
);
}

function repeatSeparator(width: number): string {
return '─'.repeat(Math.max(0, width));
}
1 change: 1 addition & 0 deletions src/cli-v2/services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Current domains:
rendering.
- `approvals/`: terminal approval choices, decisions, and keyboard-specific
behavior.
- `commands/`: terminal prompt slash-command parsing and dispatch.
- `sessions/`: terminal session lifecycle mechanics such as stream buffering
API runtime defaults, subscriptions, and run-state polling that are specific
to Ink rendering and cli-v2 store coordination.
26 changes: 26 additions & 0 deletions src/cli-v2/services/commands/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# CLI V2 Slash Commands

`src/cli-v2/services/commands` owns terminal prompt slash commands for the
Ink-based cli-v2 surface.

These are terminal prompt commands, not backend/core commands. Command effects
must route through the cli-v2 control-plane store, the shared control-plane API
service, or existing `src/client-shared` surfaces. Do not import the old TUI
from `src/cli/chat`, core services, server controllers, or backend DTO modules
from this folder.

Keep this domain narrow until cli-v2 has more command behavior to own. Add
commands here when they are terminal-specific prompt interactions; move shared
API-result projection to `src/client-shared` only when web-v2 and cli-v2 can
reuse it directly.

## Shape

- `terminal-slash-command-parser.ts`: parses slash command text and provides
match predicates.
- `terminal-slash-command-registry.ts`: composes command modules, exposes
hints, and dispatches parsed commands.
- `modules/`: terminal command groups. Add a new command group by creating a
module factory and registering it in `modules/terminal-command-modules.ts`.
- `terminal-slash-command-service.ts`: cli-v2 store-facing facade for help,
unknown-command handling, and registry dispatch.
25 changes: 25 additions & 0 deletions src/cli-v2/services/commands/modules/auth-commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { TerminalSlashCommandParser } from '../terminal-slash-command-parser.js';
import type { TerminalSlashCommandModule } from '../types.js';
import { terminalSlashStatusResult } from './results.js';

export function createTerminalAuthSlashCommandModule(): TerminalSlashCommandModule {
return {
id: 'auth',
hints: [
{ command: '/auth', description: 'show auth guidance for cli-v2' },
],
commands: [
{
id: 'auth.guidance',
syntax: '/auth',
description: 'show auth guidance for cli-v2',
match: TerminalSlashCommandParser.matchesExact('/auth'),
execute: () => terminalSlashStatusResult(
'Auth commands are not available inside cli-v2 yet',
'Use the top-level heddle auth command outside this TUI.',
'warning',
),
},
],
};
}
Loading
Loading