From 1d3e0fec3cc4dda2b011df2cc828edaa9d7cfcb8 Mon Sep 17 00:00:00 2001 From: Jay/Fienna Liang Date: Thu, 28 May 2026 16:12:37 +0800 Subject: [PATCH 1/2] Add cli-v2 slash command foundation --- .../control-plane-session-store.test.ts | 84 ++++++++- .../terminal-slash-command-service.test.ts | 178 ++++++++++++++++++ src/cli-v2/services/README.md | 1 + src/cli-v2/services/commands/README.md | 26 +++ .../commands/modules/auth-commands.ts | 25 +++ .../services/commands/modules/results.ts | 16 ++ .../commands/modules/session-commands.ts | 67 +++++++ .../modules/terminal-command-modules.ts | 10 + .../commands/terminal-slash-command-parser.ts | 45 +++++ .../terminal-slash-command-registry.ts | 72 +++++++ .../terminal-slash-command-service.ts | 54 ++++++ src/cli-v2/services/commands/types.ts | 49 +++++ .../state/control-plane-session-store.ts | 29 +++ 13 files changed, 652 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/unit/cli-v2/terminal-slash-command-service.test.ts create mode 100644 src/cli-v2/services/commands/README.md create mode 100644 src/cli-v2/services/commands/modules/auth-commands.ts create mode 100644 src/cli-v2/services/commands/modules/results.ts create mode 100644 src/cli-v2/services/commands/modules/session-commands.ts create mode 100644 src/cli-v2/services/commands/modules/terminal-command-modules.ts create mode 100644 src/cli-v2/services/commands/terminal-slash-command-parser.ts create mode 100644 src/cli-v2/services/commands/terminal-slash-command-registry.ts create mode 100644 src/cli-v2/services/commands/terminal-slash-command-service.ts create mode 100644 src/cli-v2/services/commands/types.ts diff --git a/src/__tests__/unit/cli-v2/control-plane-session-store.test.ts b/src/__tests__/unit/cli-v2/control-plane-session-store.test.ts index 167c6b7d..6eddadd9 100644 --- a/src/__tests__/unit/cli-v2/control-plane-session-store.test.ts +++ b/src/__tests__/unit/cli-v2/control-plane-session-store.test.ts @@ -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 { @@ -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 })), }; @@ -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) => { @@ -480,10 +556,10 @@ function createDeferred() { return { promise, resolve, reject }; } -function createSessionDetail(): NonNullable { +function createSessionDetail(id = 'session-1', name = 'Session 1'): NonNullable { return { - id: 'session-1', - name: 'Session 1', + id, + name, workspaceId: 'workspace-1', messageCount: 1, turnCount: 0, diff --git a/src/__tests__/unit/cli-v2/terminal-slash-command-service.test.ts b/src/__tests__/unit/cli-v2/terminal-slash-command-service.test.ts new file mode 100644 index 00000000..9921b5f5 --- /dev/null +++ b/src/__tests__/unit/cli-v2/terminal-slash-command-service.test.ts @@ -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, + }, + ]; +} diff --git a/src/cli-v2/services/README.md b/src/cli-v2/services/README.md index 7335bf08..b79a233e 100644 --- a/src/cli-v2/services/README.md +++ b/src/cli-v2/services/README.md @@ -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. diff --git a/src/cli-v2/services/commands/README.md b/src/cli-v2/services/commands/README.md new file mode 100644 index 00000000..59f6487c --- /dev/null +++ b/src/cli-v2/services/commands/README.md @@ -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. diff --git a/src/cli-v2/services/commands/modules/auth-commands.ts b/src/cli-v2/services/commands/modules/auth-commands.ts new file mode 100644 index 00000000..4efcaadf --- /dev/null +++ b/src/cli-v2/services/commands/modules/auth-commands.ts @@ -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', + ), + }, + ], + }; +} diff --git a/src/cli-v2/services/commands/modules/results.ts b/src/cli-v2/services/commands/modules/results.ts new file mode 100644 index 00000000..8f9794b0 --- /dev/null +++ b/src/cli-v2/services/commands/modules/results.ts @@ -0,0 +1,16 @@ +import type { TerminalSlashCommandResult, TerminalSlashCommandStatus } from '../types.js'; + +export function terminalSlashStatusResult( + label: string, + detail: string | undefined, + tone: TerminalSlashCommandStatus['tone'], +): TerminalSlashCommandResult { + return { + handled: true, + status: { + label, + ...(detail ? { detail } : {}), + tone, + }, + }; +} diff --git a/src/cli-v2/services/commands/modules/session-commands.ts b/src/cli-v2/services/commands/modules/session-commands.ts new file mode 100644 index 00000000..bf907d43 --- /dev/null +++ b/src/cli-v2/services/commands/modules/session-commands.ts @@ -0,0 +1,67 @@ +import type { ControlPlaneSessionView } from '@/client-shared/api/types.js'; +import { TerminalSlashCommandParser } from '../terminal-slash-command-parser.js'; +import type { + ParsedTerminalSlashCommand, + TerminalSlashCommandContext, + TerminalSlashCommandModule, + TerminalSlashCommandResult, +} from '../types.js'; +import { terminalSlashStatusResult } from './results.js'; + +export function createTerminalSessionSlashCommandModule(): TerminalSlashCommandModule { + return { + id: 'session', + hints: [ + { command: '/new [name]', description: 'create and select a new control-plane chat session' }, + { command: '/sessions', description: 'refresh and show recent control-plane chat sessions' }, + ], + commands: [ + { + id: 'session.new', + syntax: '/new [name]', + description: 'create and select a new control-plane chat session', + match: TerminalSlashCommandParser.matchesPrefix('/new'), + execute: (context, input) => createNewSession(context, input), + }, + { + id: 'session.list', + syntax: '/sessions', + description: 'refresh and show recent control-plane chat sessions', + match: TerminalSlashCommandParser.matchesExact('/sessions'), + execute: (context) => showSessions(context), + }, + ], + }; +} + +async function createNewSession( + context: TerminalSlashCommandContext, + input: ParsedTerminalSlashCommand, +): Promise { + if (context.isRunActive) { + return { + handled: true, + error: 'Cannot create a new session while the current run is active.', + }; + } + + const session = await context.createSession(input.rest ? { name: input.rest } : {}); + await context.selectSession(session.id); + return terminalSlashStatusResult('Created new session', session.name, 'success'); +} + +async function showSessions(context: TerminalSlashCommandContext): Promise { + const sessions = await context.refreshSessions(); + return terminalSlashStatusResult('Sessions refreshed', formatSessions(sessions, context.activeSessionId), 'info'); +} + +function formatSessions(sessions: ControlPlaneSessionView[], activeSessionId?: string): string { + if (!sessions.length) { + return 'No sessions available.'; + } + + return sessions + .slice(0, 8) + .map((session) => `${session.id === activeSessionId ? '* ' : ''}${session.name} (${session.id})`) + .join('\n'); +} diff --git a/src/cli-v2/services/commands/modules/terminal-command-modules.ts b/src/cli-v2/services/commands/modules/terminal-command-modules.ts new file mode 100644 index 00000000..b701adc8 --- /dev/null +++ b/src/cli-v2/services/commands/modules/terminal-command-modules.ts @@ -0,0 +1,10 @@ +import type { TerminalSlashCommandModule } from '../types.js'; +import { createTerminalAuthSlashCommandModule } from './auth-commands.js'; +import { createTerminalSessionSlashCommandModule } from './session-commands.js'; + +export function createTerminalSlashCommandModules(): TerminalSlashCommandModule[] { + return [ + createTerminalSessionSlashCommandModule(), + createTerminalAuthSlashCommandModule(), + ]; +} diff --git a/src/cli-v2/services/commands/terminal-slash-command-parser.ts b/src/cli-v2/services/commands/terminal-slash-command-parser.ts new file mode 100644 index 00000000..22dc1098 --- /dev/null +++ b/src/cli-v2/services/commands/terminal-slash-command-parser.ts @@ -0,0 +1,45 @@ +import type { ParsedTerminalSlashCommand } from './types.js'; + +/** + * Owns cli-v2 prompt slash-command text parsing and match predicates. + */ +export class TerminalSlashCommandParser { + static parse(input: string): ParsedTerminalSlashCommand | undefined { + const raw = input.trim(); + if (!raw.startsWith('/')) { + return undefined; + } + + const body = raw.slice(1).trim(); + const firstToken = body.split(/\s+/, 1)[0] ?? ''; + if (firstToken.includes('/')) { + return undefined; + } + + const root = `/${firstToken}`; + return { + raw, + root, + rest: body.slice(firstToken.length).trimStart(), + }; + } + + static isInput(input: string): boolean { + return TerminalSlashCommandParser.parse(input) !== undefined; + } + + static matchesExact(command: string): (input: ParsedTerminalSlashCommand) => boolean { + return (input) => input.raw === command; + } + + static matchesAnyExact(commands: string[]): (input: ParsedTerminalSlashCommand) => boolean { + const normalized = new Set(commands); + return (input) => normalized.has(input.raw); + } + + static matchesPrefix(prefix: string): (input: ParsedTerminalSlashCommand) => boolean { + const normalizedPrefix = prefix.endsWith(' ') ? prefix : `${prefix} `; + const exactPrefix = normalizedPrefix.trimEnd(); + return (input) => input.raw === exactPrefix || input.raw.startsWith(normalizedPrefix); + } +} diff --git a/src/cli-v2/services/commands/terminal-slash-command-registry.ts b/src/cli-v2/services/commands/terminal-slash-command-registry.ts new file mode 100644 index 00000000..091788ea --- /dev/null +++ b/src/cli-v2/services/commands/terminal-slash-command-registry.ts @@ -0,0 +1,72 @@ +import { TerminalSlashCommandParser } from './terminal-slash-command-parser.js'; +import type { + ParsedTerminalSlashCommand, + TerminalSlashCommandContext, + TerminalSlashCommandDefinition, + TerminalSlashCommandHint, + TerminalSlashCommandModule, + TerminalSlashCommandResult, +} from './types.js'; + +/** + * Registers cli-v2 slash-command modules and dispatches parsed input. + */ +export class TerminalSlashCommandRegistry { + private readonly commandList: TerminalSlashCommandDefinition[]; + private readonly hintList: TerminalSlashCommandHint[]; + + constructor(modules: TerminalSlashCommandModule[]) { + this.commandList = modules.flatMap((module) => module.commands); + this.hintList = modules.flatMap((module) => + module.hints ?? module.commands.map((command) => ({ + command: command.syntax, + description: command.description, + })), + ); + TerminalSlashCommandRegistry.validate(modules, this.commandList); + } + + hints(): TerminalSlashCommandHint[] { + return [...this.hintList]; + } + + find(input: string | ParsedTerminalSlashCommand): TerminalSlashCommandDefinition | undefined { + const parsed = typeof input === 'string' ? TerminalSlashCommandParser.parse(input) : input; + return parsed ? this.commandList.find((command) => command.match(parsed)) : undefined; + } + + async execute( + context: TerminalSlashCommandContext, + input: string | ParsedTerminalSlashCommand, + ): Promise { + const parsed = typeof input === 'string' ? TerminalSlashCommandParser.parse(input) : input; + if (!parsed) { + return undefined; + } + + const command = this.commandList.find((candidate) => candidate.match(parsed)); + return command ? await command.execute(context, parsed) : undefined; + } + + private static validate( + modules: TerminalSlashCommandModule[], + commands: TerminalSlashCommandDefinition[], + ): void { + TerminalSlashCommandRegistry.assertUnique('cli-v2 slash command module id', modules.map((module) => module.id)); + TerminalSlashCommandRegistry.assertUnique('cli-v2 slash command id', commands.map((command) => command.id)); + TerminalSlashCommandRegistry.assertUnique( + 'cli-v2 slash command syntax', + commands.flatMap((command) => [command.syntax, ...(command.aliases ?? [])]), + ); + } + + private static assertUnique(label: string, values: string[]): void { + const seen = new Set(); + values.forEach((value) => { + if (seen.has(value)) { + throw new Error(`Duplicate ${label}: ${value}`); + } + seen.add(value); + }); + } +} diff --git a/src/cli-v2/services/commands/terminal-slash-command-service.ts b/src/cli-v2/services/commands/terminal-slash-command-service.ts new file mode 100644 index 00000000..fcdfe55e --- /dev/null +++ b/src/cli-v2/services/commands/terminal-slash-command-service.ts @@ -0,0 +1,54 @@ +import { createTerminalSlashCommandModules } from './modules/terminal-command-modules.js'; +import { terminalSlashStatusResult } from './modules/results.js'; +import { TerminalSlashCommandParser } from './terminal-slash-command-parser.js'; +import { TerminalSlashCommandRegistry } from './terminal-slash-command-registry.js'; +import type { + TerminalSlashCommandContext, + TerminalSlashCommandResult, +} from './types.js'; + +/** + * Owns cli-v2 terminal prompt slash-command parsing and dispatch. + */ +export class TerminalSlashCommandService { + private readonly registry = new TerminalSlashCommandRegistry(createTerminalSlashCommandModules()); + + isSlashCommand(input: string): boolean { + return TerminalSlashCommandParser.isInput(input); + } + + async execute(input: string, context: TerminalSlashCommandContext): Promise { + const parsed = TerminalSlashCommandParser.parse(input); + if (!parsed) { + return { handled: false }; + } + + if (parsed.raw === '/help') { + return terminalSlashStatusResult('CLI v2 commands', this.formatHelp(), 'info'); + } + + const result = await this.registry.execute(context, parsed); + if (result) { + return result; + } + + return { + handled: true, + error: `Unknown cli-v2 slash command: ${parsed.root}. Use /help to inspect supported commands.`, + }; + } + + private formatHelp(): string { + return [ + { command: '/help', description: 'list supported cli-v2 slash commands' }, + ...this.registry.hints(), + ] + .map((hint) => `${hint.command} - ${hint.description}`) + .join('\n'); + } +} + +export type { + TerminalSlashCommandContext, + TerminalSlashCommandResult, +} from './types.js'; diff --git a/src/cli-v2/services/commands/types.ts b/src/cli-v2/services/commands/types.ts new file mode 100644 index 00000000..10a9c743 --- /dev/null +++ b/src/cli-v2/services/commands/types.ts @@ -0,0 +1,49 @@ +import type { ControlPlaneSessionView } from '@/client-shared/api/types.js'; + +export type TerminalSlashCommandStatus = { + label: string; + detail?: string; + tone: 'info' | 'success' | 'warning' | 'error'; +}; + +export type TerminalSlashCommandResult = + | { handled: false } + | { handled: true; status: TerminalSlashCommandStatus; error?: undefined } + | { handled: true; error: string; status?: undefined }; + +export type TerminalSlashCommandContext = { + activeSessionId?: string; + isRunActive: boolean; + refreshSessions: () => Promise; + createSession: (input: { name?: string }) => Promise; + selectSession: (sessionId: string) => Promise; +}; + +export type ParsedTerminalSlashCommand = { + raw: string; + root: string; + rest: string; +}; + +export type TerminalSlashCommandHint = { + command: string; + description: string; +}; + +export type TerminalSlashCommandDefinition = { + id: string; + syntax: string; + description: string; + aliases?: string[]; + match: (input: ParsedTerminalSlashCommand) => boolean; + execute: ( + context: TerminalSlashCommandContext, + input: ParsedTerminalSlashCommand, + ) => Promise | TerminalSlashCommandResult; +}; + +export type TerminalSlashCommandModule = { + id: string; + hints?: TerminalSlashCommandHint[]; + commands: TerminalSlashCommandDefinition[]; +}; diff --git a/src/cli-v2/state/control-plane-session-store.ts b/src/cli-v2/state/control-plane-session-store.ts index 50335cff..833d31ca 100644 --- a/src/cli-v2/state/control-plane-session-store.ts +++ b/src/cli-v2/state/control-plane-session-store.ts @@ -18,6 +18,7 @@ import { SessionRunStatePollerService, type SessionRunStatePollAddress, } from '../services/sessions/session-run-state-poller-service.js'; +import { TerminalSlashCommandService } from '../services/commands/terminal-slash-command-service.js'; import type { ControlPlaneApprovalDecision, ControlPlanePendingApproval, @@ -87,6 +88,7 @@ export class ControlPlaneSessionStore { private readonly subscriptions: ControlPlaneSessionSubscriptionService; private readonly assistantStreamBuffer: AssistantStreamBufferService; private readonly runStatePoller: SessionRunStatePollerService; + private readonly slashCommands = new TerminalSlashCommandService(); constructor(options: ControlPlaneSessionStoreOptions) { this.api = new ControlPlaneSessionApiService(options); @@ -203,6 +205,11 @@ export class ControlPlaneSessionStore { return; } + if (this.slashCommands.isSlashCommand(trimmed)) { + await this.executeSlashCommand(trimmed); + return; + } + if (this.snapshotValue.running) { this.setSnapshot({ latestUpdate: { @@ -281,6 +288,28 @@ export class ControlPlaneSessionStore { } } + private async executeSlashCommand(prompt: string): Promise { + try { + const result = await this.slashCommands.execute(prompt, { + activeSessionId: this.snapshotValue.activeSessionId, + isRunActive: this.snapshotValue.running || this.snapshotValue.submitting || this.snapshotValue.cancelling, + refreshSessions: () => this.refreshSessions(), + createSession: (input) => this.createSession(input), + selectSession: (sessionId) => this.selectSession(sessionId), + }); + + if (!result.handled) { + return; + } + + this.setSnapshot(result.error + ? { error: result.error } + : { error: undefined, latestUpdate: result.status }); + } catch (error) { + this.setSnapshot({ error: formatError(error) }); + } + } + async cancelRun(): Promise { const workspaceId = this.requireWorkspaceId(); const sessionId = this.requireActiveSessionId(); From 18c3bac53724f98ff0f270499efa4aaaee9ad43b Mon Sep 17 00:00:00 2001 From: Jay/Fienna Liang Date: Thu, 28 May 2026 19:08:25 +0800 Subject: [PATCH 2/2] Add horizontal separtor around prompt input --- src/cli-v2/components/PromptInput.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/cli-v2/components/PromptInput.tsx b/src/cli-v2/components/PromptInput.tsx index ca277708..71ba7fe3 100644 --- a/src/cli-v2/components/PromptInput.tsx +++ b/src/cli-v2/components/PromptInput.tsx @@ -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({ @@ -19,6 +19,9 @@ export function PromptInput({ onChange: Dispatch>; onSubmit: (value: string) => void; }) { + const { stdout } = useStdout(); + const separator = repeatSeparator((stdout.columns ?? 0) - 2); + useInput((input, key) => { if (disabled) { return; @@ -45,11 +48,21 @@ export function PromptInput({ return ( + + {separator} + {activity ? {activity.text} : null} {value || {placeholder}} + + {separator} + ); } + +function repeatSeparator(width: number): string { + return '─'.repeat(Math.max(0, width)); +}