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
7 changes: 7 additions & 0 deletions src/__tests__/unit/tui/local-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));

Expand Down
4 changes: 2 additions & 2 deletions src/cli/chat/commands/debug-snapshot-command.ts
Original file line number Diff line number Diff line change
@@ -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> | string;
};

export function createTuiDebugSnapshotCommandModule(): SlashCommandModule<
LocalCommandResult,
SlashCommandResult,
TuiSlashCommandContext
> {
return {
Expand Down
69 changes: 11 additions & 58 deletions src/cli/chat/state/local-commands.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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}`,
Expand All @@ -105,47 +111,23 @@ 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(
draft: string,
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<LocalCommandResult> {
export async function runLocalCommand(args: LocalCommandArgs): Promise<SlashCommandResult> {
const trimmed = args.prompt.trim();
if (!isLikelyLocalCommand(trimmed)) {
return { handled: false };
Expand All @@ -168,7 +150,7 @@ export async function runLocalCommand(args: LocalCommandArgs): Promise<LocalComm
return messageResult(`Unknown command: ${trimmed}. Use /help for available commands.`);
}

function messageResult(message: string, sessionId?: string): LocalCommandResult {
function messageResult(message: string, sessionId?: string): SlashCommandResult {
return {
handled: true,
kind: 'message',
Expand All @@ -177,35 +159,6 @@ function messageResult(message: string, sessionId?: string): LocalCommandResult
};
}

function hintCommandToCompletionCandidate(command: string): string {
const placeholderMatch = command.match(/\s(?:<[^>]+>|\[[^\]]+\])/);
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;
}
4 changes: 3 additions & 1 deletion src/cli/chat/state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 0 additions & 6 deletions src/core/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
24 changes: 15 additions & 9 deletions src/core/commands/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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.

9 changes: 5 additions & 4 deletions src/core/commands/slash/modules/auth/auth-commands.ts
Original file line number Diff line number Diff line change
@@ -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<LlmProvider>(['openai', 'anthropic', 'google']);

export function createAuthSlashCommandModule(): SlashCommandModule<CoreSlashCommandResult, SlashCommandExecutionContext> {
export function createAuthSlashCommandModule(): SlashCommandModule<SlashCommandResult, SlashCommandExecutionContext> {
return {
id: 'auth',
hints: [
Expand Down Expand Up @@ -45,7 +46,7 @@ export function createAuthSlashCommandModule(): SlashCommandModule<CoreSlashComm
async function login(
context: SlashCommandExecutionContext,
value: string,
): Promise<CoreSlashCommandResult> {
): Promise<SlashCommandResult> {
const provider = parseAuthProvider(value);
if (!provider) {
return slashMessageResult('Usage: /auth login <provider>');
Expand All @@ -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 <provider>');
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CoreSlashCommandResult, SlashCommandExecutionContext> {
export function createCompactionSlashCommandModule(): SlashCommandModule<SlashCommandResult, SlashCommandExecutionContext> {
return {
id: 'compaction',
hints: [
Expand Down
5 changes: 3 additions & 2 deletions src/core/commands/slash/modules/context.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -42,4 +43,4 @@ export type SlashCommandExecutionContext = {
};
};

export type CoreSlashCommandResult = LocalCommandResult;
export type { SlashCommandResult };
5 changes: 3 additions & 2 deletions src/core/commands/slash/modules/core-command-modules.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 [
Expand Down
5 changes: 3 additions & 2 deletions src/core/commands/slash/modules/drift/drift-commands.ts
Original file line number Diff line number Diff line change
@@ -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<CoreSlashCommandResult, SlashCommandExecutionContext> {
export function createDriftSlashCommandModule(): SlashCommandModule<SlashCommandResult, SlashCommandExecutionContext> {
return {
id: 'drift',
hints: [
Expand Down
15 changes: 8 additions & 7 deletions src/core/commands/slash/modules/heartbeat/heartbeat-commands.ts
Original file line number Diff line number Diff line change
@@ -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<CoreSlashCommandResult, SlashCommandExecutionContext> {
export function createHeartbeatSlashCommandModule(): SlashCommandModule<SlashCommandResult, SlashCommandExecutionContext> {
return {
id: 'heartbeat',
hints: [
Expand Down Expand Up @@ -59,7 +60,7 @@ export function createHeartbeatSlashCommandModule(): SlashCommandModule<CoreSlas

export async function listHeartbeatTasksMessage(
context: Pick<SlashCommandExecutionContext, 'heartbeat'>,
): Promise<CoreSlashCommandResult> {
): Promise<SlashCommandResult> {
const tasks = await context.heartbeat.listTasks();
if (!tasks.length) {
return slashMessageResult('No heartbeat tasks found.');
Expand All @@ -71,7 +72,7 @@ export async function listHeartbeatTasksMessage(
export async function listHeartbeatRunsMessage(
context: Pick<SlashCommandExecutionContext, 'heartbeat'>,
taskId: string,
): Promise<CoreSlashCommandResult> {
): Promise<SlashCommandResult> {
const trimmedTaskId = taskId.trim();
const runs = await context.heartbeat.listRunRecords({
taskId: trimmedTaskId || undefined,
Expand Down Expand Up @@ -157,7 +158,7 @@ function matchesRequiredHeartbeatArgument(prefix: string): (input: { raw: string
async function showHeartbeatTask(
context: Pick<SlashCommandExecutionContext, 'heartbeat'>,
value: string,
): Promise<CoreSlashCommandResult> {
): Promise<SlashCommandResult> {
const taskId = value.trim();
const tasks = await context.heartbeat.listTasks();
const task = tasks.find((candidate) => candidate.id === taskId);
Expand All @@ -167,7 +168,7 @@ async function showHeartbeatTask(
async function showHeartbeatRun(
context: Pick<SlashCommandExecutionContext, 'heartbeat'>,
value: string,
): Promise<CoreSlashCommandResult> {
): Promise<SlashCommandResult> {
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}`);
Expand All @@ -176,7 +177,7 @@ async function showHeartbeatRun(
async function continueHeartbeatRun(
context: Pick<SlashCommandExecutionContext, 'heartbeat'>,
value: string,
): Promise<CoreSlashCommandResult> {
): Promise<SlashCommandResult> {
const request = parseHeartbeatRunRequest(value);
const run = await resolveHeartbeatRun(context, request);
if (!run) {
Expand Down
Loading
Loading