diff --git a/src/__tests__/unit/tui/local-commands.test.ts b/src/__tests__/unit/tui/local-commands.test.ts index ea5f038..6085abe 100644 --- a/src/__tests__/unit/tui/local-commands.test.ts +++ b/src/__tests__/unit/tui/local-commands.test.ts @@ -54,6 +54,13 @@ describe('runLocalCommand', () => { expect(isLikelyLocalCommand('/Users/roackb2/Desktop/screenshot.png')).toBe(false); }); + it('does not expose slash hints or completions for absolute unix paths', () => { + const pathLikeDraft = '/Users/roackb2/Desktop/screenshot.png'; + + expect(getLocalCommandHints(pathLikeDraft, 'session-1', [])).toEqual([]); + expect(autocompleteLocalCommand(pathLikeDraft, 'session-1', [])).toBeUndefined(); + }); + it('lists grouped common built-in model choices with multi-line formatting', async () => { const result = await runLocalCommand(createCommandArgs({ prompt: '/model list' })); diff --git a/src/cli/chat/commands/debug-snapshot-command.ts b/src/cli/chat/commands/debug-snapshot-command.ts index 6df0731..67e11a0 100644 --- a/src/cli/chat/commands/debug-snapshot-command.ts +++ b/src/cli/chat/commands/debug-snapshot-command.ts @@ -1,13 +1,13 @@ import { matchesExactSlashCommand } from '../../../core/commands/slash/parser.js'; +import type { SlashCommandResult } from '../../../core/commands/slash/result-types.js'; import type { SlashCommandModule } from '../../../core/commands/slash/types.js'; -import type { LocalCommandResult } from '../state/types.js'; export type TuiSlashCommandContext = { saveTuiSnapshot?: () => Promise | string; }; export function createTuiDebugSnapshotCommandModule(): SlashCommandModule< - LocalCommandResult, + SlashCommandResult, TuiSlashCommandContext > { return { diff --git a/src/cli/chat/state/local-commands.ts b/src/cli/chat/state/local-commands.ts index 791dd44..8a7b742 100644 --- a/src/cli/chat/state/local-commands.ts +++ b/src/cli/chat/state/local-commands.ts @@ -1,8 +1,10 @@ -import type { ChatSession, LocalCommandResult } from './types.js'; +import type { ChatSession } from './types.js'; import type { OpenAiOAuthCredential } from '../../../core/auth/openai-oauth.js'; import type { ProviderCredentialSource } from '../utils/runtime.js'; +import { autocompleteSlashCommand, filterSlashCommandHints } from '../../../core/commands/slash/autocomplete.js'; import { createSlashCommandRegistry } from '../../../core/commands/slash/registry.js'; import { createCoreSlashCommandModules } from '../../../core/commands/slash/modules/core-command-modules.js'; +import type { SlashCommandResult } from '../../../core/commands/slash/result-types.js'; import { createTuiSlashCommandContext } from '../adapters/slash-command-context.js'; import { createTuiDebugSnapshotCommandModule } from '../commands/debug-snapshot-command.js'; @@ -97,6 +99,10 @@ export function getLocalCommandHints( sessions: ChatSession[], ): LocalCommandHint[] { const trimmed = draft.trim(); + if (!isLikelyLocalCommand(trimmed)) { + return []; + } + if (trimmed.startsWith('/session switch ')) { const sessionHints = sessions.map((session) => ({ command: `/session switch ${session.id}`, @@ -105,8 +111,7 @@ export function getLocalCommandHints( return sessionHints.filter((hint) => hint.command.startsWith(trimmed)); } - const filtered = HELP_HINTS.filter((hint) => hint.command.startsWith(trimmed) || trimmed === '/'); - return filtered.length > 0 ? filtered : HELP_HINTS; + return filterSlashCommandHints(trimmed, HELP_HINTS); } export function autocompleteLocalCommand( @@ -114,38 +119,15 @@ export function autocompleteLocalCommand( activeSessionId: string, sessions: ChatSession[], ): string | undefined { - const leadingWhitespace = draft.match(/^\s*/)?.[0] ?? ''; const trimmedStart = draft.trimStart(); if (!isLikelyLocalCommand(trimmedStart)) { return undefined; } - const completionCandidates = Array.from( - new Set( - getLocalCommandHints(trimmedStart, activeSessionId, sessions) - .map((hint) => hintCommandToCompletionCandidate(hint.command)) - .filter((candidate) => candidate.startsWith(trimmedStart)), - ), - ); - if (completionCandidates.length === 0) { - return undefined; - } - - const sharedPrefix = longestSharedPrefix(completionCandidates); - const expandedPrefix = - completionCandidates.some((candidate) => candidate.startsWith(`${sharedPrefix} `)) ? `${sharedPrefix} ` : sharedPrefix; - if (expandedPrefix.length > trimmedStart.length) { - return `${leadingWhitespace}${expandedPrefix}`; - } - - if (completionCandidates.length === 1 && completionCandidates[0] !== trimmedStart) { - return `${leadingWhitespace}${completionCandidates[0]}`; - } - - return undefined; + return autocompleteSlashCommand(draft, getLocalCommandHints(trimmedStart, activeSessionId, sessions)); } -export async function runLocalCommand(args: LocalCommandArgs): Promise { +export async function runLocalCommand(args: LocalCommandArgs): Promise { const trimmed = args.prompt.trim(); if (!isLikelyLocalCommand(trimmed)) { return { handled: false }; @@ -168,7 +150,7 @@ export async function runLocalCommand(args: LocalCommandArgs): Promise]+>|\[[^\]]+\])/); - if (!placeholderMatch || placeholderMatch.index === undefined) { - return command; - } - - return `${command.slice(0, placeholderMatch.index)} `; -} - -function longestSharedPrefix(values: string[]): string { - if (values.length === 0) { - return ''; - } - - let prefix = values[0] ?? ''; - for (const value of values.slice(1)) { - let index = 0; - while (index < prefix.length && index < value.length && prefix[index] === value[index]) { - index += 1; - } - prefix = prefix.slice(0, index); - if (!prefix) { - break; - } - } - - return prefix; -} - function capitalizeFirst(value: string): string { return value.length > 0 ? `${value[0]!.toUpperCase()}${value.slice(1)}.` : value; } diff --git a/src/cli/chat/state/types.ts b/src/cli/chat/state/types.ts index 1697a5c..bd609d2 100644 --- a/src/cli/chat/state/types.ts +++ b/src/cli/chat/state/types.ts @@ -6,5 +6,7 @@ export type { ChatSession, PendingApproval, ApprovalChoice, - LocalCommandResult, } from '../../../core/chat/types.js'; +export type { + SlashCommandResult, +} from '../../../core/commands/slash/result-types.js'; diff --git a/src/core/chat/types.ts b/src/core/chat/types.ts index 1a89d62..6e7de70 100644 --- a/src/core/chat/types.ts +++ b/src/core/chat/types.ts @@ -93,9 +93,3 @@ export type PendingApproval = { }; export type ApprovalChoice = 'approve' | 'allow_project' | 'deny'; - -export type LocalCommandResult = - | { handled: false } - | { handled: true; kind: 'message'; message: string; sessionId?: string } - | { handled: true; kind: 'continue'; sessionId?: string; message?: string } - | { handled: true; kind: 'execute'; prompt: string; displayText: string; message?: string }; diff --git a/src/core/commands/README.md b/src/core/commands/README.md index e9431ff..b25a83f 100644 --- a/src/core/commands/README.md +++ b/src/core/commands/README.md @@ -1,15 +1,15 @@ # Commands -The commands domain is the planned home for text-command parsing, registration, -autocomplete, and cross-host command behavior. It starts as documentation in M0; -implementation begins with the slash-command milestones. +The commands domain owns text-command parsing, registration, autocomplete, and +cross-host command behavior. Host layers compose command modules into their own +registries and render the command results. ## Owns - Generic slash-command parser and registry infrastructure. - Command metadata such as syntax, description, aliases, and hints. - Cross-host command modules for behavior that is not specific to TUI rendering. -- Command result types that adapters can render or execute. +- Slash command result types that adapters can render or execute. ## Does Not Own @@ -19,9 +19,12 @@ implementation begins with the slash-command milestones. credential storage. Commands call those through ports. - Turn execution middleware. -## Planned Public Entry Points +## Current Entry Points -- `slash/types.ts`: command, context, result, and parsed input types. +- `slash/types.ts`: command metadata, parsed input, hints, and registry-facing + generic types. +- `slash/result-types.ts`: `SlashCommandResult`, the command-domain result + contract. - `slash/parser.ts`: parse slash command text without treating absolute paths as commands. - `slash/registry.ts`: register and dispatch command modules. @@ -48,8 +51,12 @@ implementation begins with the slash-command milestones. ## Tests -- Planned: `src/__tests__/unit/core/slash-commands.test.ts` -- Existing behavior lock: `src/__tests__/unit/tui/local-commands.test.ts` +- Core parser, registry, and autocomplete: + `src/__tests__/unit/core/slash-commands.test.ts` +- Core command modules: + `src/__tests__/unit/core/slash-command-modules.test.ts` +- TUI behavior lock: + `src/__tests__/unit/tui/local-commands.test.ts` - TUI command integration: `src/__tests__/integration/tui/session-cli.test.ts` ## Notes For Coding Agents @@ -58,4 +65,3 @@ implementation begins with the slash-command milestones. itself. - Prefer command arrays and registries over switchboards. - Keep command modules UI-free unless they live under a host-specific folder. - diff --git a/src/core/commands/slash/modules/auth/auth-commands.ts b/src/core/commands/slash/modules/auth/auth-commands.ts index 5094fb9..c71c42b 100644 --- a/src/core/commands/slash/modules/auth/auth-commands.ts +++ b/src/core/commands/slash/modules/auth/auth-commands.ts @@ -1,12 +1,13 @@ import { matchesAnyExactSlashCommand, matchesSlashCommandPrefix } from '../../parser.js'; +import type { SlashCommandResult } from '../../result-types.js'; import type { SlashCommandModule } from '../../types.js'; -import type { CoreSlashCommandResult, SlashCommandExecutionContext } from '../context.js'; +import type { SlashCommandExecutionContext } from '../context.js'; import { argumentAfterPrefix, formatCommandError, slashMessageResult } from '../results.js'; import type { LlmProvider } from '../../../../llm/types.js'; const AUTH_PROVIDERS = new Set(['openai', 'anthropic', 'google']); -export function createAuthSlashCommandModule(): SlashCommandModule { +export function createAuthSlashCommandModule(): SlashCommandModule { return { id: 'auth', hints: [ @@ -45,7 +46,7 @@ export function createAuthSlashCommandModule(): SlashCommandModule { +): Promise { const provider = parseAuthProvider(value); if (!provider) { return slashMessageResult('Usage: /auth login '); @@ -61,7 +62,7 @@ async function login( function logout( context: SlashCommandExecutionContext, value: string, -): CoreSlashCommandResult { +): SlashCommandResult { const provider = parseAuthProvider(value); if (!provider) { return slashMessageResult('Usage: /auth logout '); diff --git a/src/core/commands/slash/modules/compaction/compaction-commands.ts b/src/core/commands/slash/modules/compaction/compaction-commands.ts index 93ba8f5..b306e08 100644 --- a/src/core/commands/slash/modules/compaction/compaction-commands.ts +++ b/src/core/commands/slash/modules/compaction/compaction-commands.ts @@ -1,9 +1,10 @@ import { matchesExactSlashCommand } from '../../parser.js'; +import type { SlashCommandResult } from '../../result-types.js'; import type { SlashCommandModule } from '../../types.js'; -import type { CoreSlashCommandResult, SlashCommandExecutionContext } from '../context.js'; +import type { SlashCommandExecutionContext } from '../context.js'; import { slashMessageResult } from '../results.js'; -export function createCompactionSlashCommandModule(): SlashCommandModule { +export function createCompactionSlashCommandModule(): SlashCommandModule { return { id: 'compaction', hints: [ diff --git a/src/core/commands/slash/modules/context.ts b/src/core/commands/slash/modules/context.ts index 251cdf4..0e81bbd 100644 --- a/src/core/commands/slash/modules/context.ts +++ b/src/core/commands/slash/modules/context.ts @@ -1,10 +1,11 @@ -import type { ChatSession, LocalCommandResult } from '../../../chat/types.js'; +import type { ChatSession } from '../../../chat/types.js'; import type { LlmProvider } from '../../../llm/types.js'; import type { ProviderCredentialSource } from '../../../runtime/api-keys.js'; import type { HeartbeatTask, HeartbeatTaskRunRecordEntry, } from '../../../runtime/heartbeat-task-store.js'; +import type { SlashCommandResult } from '../result-types.js'; export type SlashCommandExecutionContext = { model: { @@ -42,4 +43,4 @@ export type SlashCommandExecutionContext = { }; }; -export type CoreSlashCommandResult = LocalCommandResult; +export type { SlashCommandResult }; diff --git a/src/core/commands/slash/modules/core-command-modules.ts b/src/core/commands/slash/modules/core-command-modules.ts index dd358de..0123f22 100644 --- a/src/core/commands/slash/modules/core-command-modules.ts +++ b/src/core/commands/slash/modules/core-command-modules.ts @@ -1,5 +1,6 @@ import type { SlashCommandModule } from '../types.js'; -import type { CoreSlashCommandResult, SlashCommandExecutionContext } from './context.js'; +import type { SlashCommandResult } from '../result-types.js'; +import type { SlashCommandExecutionContext } from './context.js'; import { createAuthSlashCommandModule } from './auth/auth-commands.js'; import { createCompactionSlashCommandModule } from './compaction/compaction-commands.js'; import { createDriftSlashCommandModule } from './drift/drift-commands.js'; @@ -8,7 +9,7 @@ import { createModelSlashCommandModule } from './model/model-commands.js'; import { createSessionSlashCommandModule } from './session/session-commands.js'; export function createCoreSlashCommandModules(): SlashCommandModule< - CoreSlashCommandResult, + SlashCommandResult, SlashCommandExecutionContext >[] { return [ diff --git a/src/core/commands/slash/modules/drift/drift-commands.ts b/src/core/commands/slash/modules/drift/drift-commands.ts index e993afc..18df79e 100644 --- a/src/core/commands/slash/modules/drift/drift-commands.ts +++ b/src/core/commands/slash/modules/drift/drift-commands.ts @@ -1,9 +1,10 @@ import { matchesAnyExactSlashCommand, matchesExactSlashCommand } from '../../parser.js'; +import type { SlashCommandResult } from '../../result-types.js'; import type { SlashCommandModule } from '../../types.js'; -import type { CoreSlashCommandResult, SlashCommandExecutionContext } from '../context.js'; +import type { SlashCommandExecutionContext } from '../context.js'; import { slashMessageResult } from '../results.js'; -export function createDriftSlashCommandModule(): SlashCommandModule { +export function createDriftSlashCommandModule(): SlashCommandModule { return { id: 'drift', hints: [ diff --git a/src/core/commands/slash/modules/heartbeat/heartbeat-commands.ts b/src/core/commands/slash/modules/heartbeat/heartbeat-commands.ts index 3ad112f..304c0dd 100644 --- a/src/core/commands/slash/modules/heartbeat/heartbeat-commands.ts +++ b/src/core/commands/slash/modules/heartbeat/heartbeat-commands.ts @@ -1,13 +1,14 @@ import { matchesExactSlashCommand, matchesSlashCommandPrefix } from '../../parser.js'; +import type { SlashCommandResult } from '../../result-types.js'; import type { SlashCommandModule } from '../../types.js'; import type { HeartbeatTask, HeartbeatTaskRunRecordEntry, } from '../../../../runtime/heartbeat-task-store.js'; -import type { CoreSlashCommandResult, SlashCommandExecutionContext } from '../context.js'; +import type { SlashCommandExecutionContext } from '../context.js'; import { argumentAfterPrefix, slashMessageResult } from '../results.js'; -export function createHeartbeatSlashCommandModule(): SlashCommandModule { +export function createHeartbeatSlashCommandModule(): SlashCommandModule { return { id: 'heartbeat', hints: [ @@ -59,7 +60,7 @@ export function createHeartbeatSlashCommandModule(): SlashCommandModule, -): Promise { +): Promise { const tasks = await context.heartbeat.listTasks(); if (!tasks.length) { return slashMessageResult('No heartbeat tasks found.'); @@ -71,7 +72,7 @@ export async function listHeartbeatTasksMessage( export async function listHeartbeatRunsMessage( context: Pick, taskId: string, -): Promise { +): Promise { const trimmedTaskId = taskId.trim(); const runs = await context.heartbeat.listRunRecords({ taskId: trimmedTaskId || undefined, @@ -157,7 +158,7 @@ function matchesRequiredHeartbeatArgument(prefix: string): (input: { raw: string async function showHeartbeatTask( context: Pick, value: string, -): Promise { +): Promise { const taskId = value.trim(); const tasks = await context.heartbeat.listTasks(); const task = tasks.find((candidate) => candidate.id === taskId); @@ -167,7 +168,7 @@ async function showHeartbeatTask( async function showHeartbeatRun( context: Pick, value: string, -): Promise { +): Promise { const request = parseHeartbeatRunRequest(value); const run = await resolveHeartbeatRun(context, request); return run ? slashMessageResult(formatHeartbeatRun(run)) : slashMessageResult(`Heartbeat run not found for task ${request.taskId}: ${request.runRef}`); @@ -176,7 +177,7 @@ async function showHeartbeatRun( async function continueHeartbeatRun( context: Pick, value: string, -): Promise { +): Promise { const request = parseHeartbeatRunRequest(value); const run = await resolveHeartbeatRun(context, request); if (!run) { diff --git a/src/core/commands/slash/modules/model/model-commands.ts b/src/core/commands/slash/modules/model/model-commands.ts index 7b9e79b..9887c2c 100644 --- a/src/core/commands/slash/modules/model/model-commands.ts +++ b/src/core/commands/slash/modules/model/model-commands.ts @@ -1,6 +1,7 @@ import { matchesAnyExactSlashCommand, matchesExactSlashCommand, matchesSlashCommandPrefix } from '../../parser.js'; +import type { SlashCommandResult } from '../../result-types.js'; import type { SlashCommandModule } from '../../types.js'; -import type { CoreSlashCommandResult, SlashCommandExecutionContext } from '../context.js'; +import type { SlashCommandExecutionContext } from '../context.js'; import { argumentAfterPrefix, slashMessageResult } from '../results.js'; import { COMMON_BUILT_IN_MODELS, formatBuiltInModelGroups } from '../../../../llm/openai-models.js'; import { credentialModeFromSource, validateModelCredentialCompatibility } from '../../../../llm/model-policy.js'; @@ -9,12 +10,12 @@ import type { LlmProvider } from '../../../../llm/types.js'; export const MODEL_LIST_MESSAGE = ['Common built-in model choices', '', formatBuiltInModelGroups()].join('\n'); export const MODEL_SET_HELP_MESSAGE = 'Use /model set to filter models, then use arrows and Enter to choose one.'; -const MODEL_SUBCOMMAND_RESULTS = new Map([ +const MODEL_SUBCOMMAND_RESULTS = new Map([ ['list', slashMessageResult(MODEL_LIST_MESSAGE)], ['set', slashMessageResult(MODEL_SET_HELP_MESSAGE)], ]); -export function createModelSlashCommandModule(): SlashCommandModule { +export function createModelSlashCommandModule(): SlashCommandModule { return { id: 'model', hints: [ @@ -60,7 +61,7 @@ export function createModelSlashCommandModule(): SlashCommandModule'); } diff --git a/src/core/commands/slash/modules/results.ts b/src/core/commands/slash/modules/results.ts index d41f25f..c810219 100644 --- a/src/core/commands/slash/modules/results.ts +++ b/src/core/commands/slash/modules/results.ts @@ -1,7 +1,7 @@ import type { ParsedSlashCommand } from '../types.js'; -import type { CoreSlashCommandResult } from './context.js'; +import type { SlashCommandResult } from '../result-types.js'; -export function slashMessageResult(message: string, sessionId?: string): CoreSlashCommandResult { +export function slashMessageResult(message: string, sessionId?: string): SlashCommandResult { return { handled: true, kind: 'message', diff --git a/src/core/commands/slash/modules/session/session-commands.ts b/src/core/commands/slash/modules/session/session-commands.ts index e4463cf..b4f6c1e 100644 --- a/src/core/commands/slash/modules/session/session-commands.ts +++ b/src/core/commands/slash/modules/session/session-commands.ts @@ -1,10 +1,11 @@ import { matchesExactSlashCommand, matchesSlashCommandPrefix } from '../../parser.js'; +import type { SlashCommandResult } from '../../result-types.js'; import type { SlashCommandModule } from '../../types.js'; import type { ChatSession } from '../../../../chat/types.js'; -import type { CoreSlashCommandResult, SlashCommandExecutionContext } from '../context.js'; +import type { SlashCommandExecutionContext } from '../context.js'; import { argumentAfterPrefix, slashMessageResult } from '../results.js'; -export function createSessionSlashCommandModule(): SlashCommandModule { +export function createSessionSlashCommandModule(): SlashCommandModule { return { id: 'session', hints: [ @@ -116,7 +117,7 @@ export function resolveSessionReference(args: { function createSession( context: SlashCommandExecutionContext, name: string, -): CoreSlashCommandResult { +): SlashCommandResult { const session = context.session.create(name || undefined); return slashMessageResult(`Created and switched to ${session.id} (${session.name}).`, session.id); } @@ -124,7 +125,7 @@ function createSession( function switchSession( context: SlashCommandExecutionContext, value: string, -): CoreSlashCommandResult { +): SlashCommandResult { const session = findSession(context, value); if (!session) { return slashMessageResult(`Unknown session: ${value}. Use /session list to inspect available sessions.`); @@ -137,7 +138,7 @@ function switchSession( function continueSession( context: SlashCommandExecutionContext, value: string, -): CoreSlashCommandResult { +): SlashCommandResult { const session = findSession(context, value); if (!session) { return slashMessageResult(`Unknown session: ${value}.\nUse /session list to inspect available sessions.`); @@ -154,7 +155,7 @@ function continueSession( function renameSession( context: SlashCommandExecutionContext, name: string, -): CoreSlashCommandResult { +): SlashCommandResult { if (!name) { return slashMessageResult('Usage: /session rename '); } @@ -166,7 +167,7 @@ function renameSession( function closeSession( context: SlashCommandExecutionContext, value: string, -): CoreSlashCommandResult { +): SlashCommandResult { const session = findSession(context, value); if (!session) { return slashMessageResult(`Unknown session: ${value}.\nUse /session list to inspect available sessions.`); diff --git a/src/core/commands/slash/result-types.ts b/src/core/commands/slash/result-types.ts new file mode 100644 index 0000000..839fa8d --- /dev/null +++ b/src/core/commands/slash/result-types.ts @@ -0,0 +1,5 @@ +export type SlashCommandResult = + | { handled: false } + | { handled: true; kind: 'message'; message: string; sessionId?: string } + | { handled: true; kind: 'continue'; sessionId?: string; message?: string } + | { handled: true; kind: 'execute'; prompt: string; displayText: string; message?: string };