Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/cli/ai/description-autocomplete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { CombinedAutocompleteProvider } from '@earendil-works/pi-tui';
import type { AutocompleteSuggestions } from '@earendil-works/pi-tui';
import type { SlashCommandDef } from 'cli/ai/slash-commands';

/**
* Autocomplete provider that extends pi-tui's slash-command matching (command
* names only) with a case-insensitive substring match against the commands'
* descriptions, so e.g. `/migrate` surfaces the `liberate` command.
* Description matches are appended after pi-tui's own name matches; selecting
* one inserts the real command name (pi-tui's applyCompletion replaces the
* whole typed `/token`).
*/
export class DescriptionAwareAutocompleteProvider extends CombinedAutocompleteProvider {
private readonly slashCommands: SlashCommandDef[];

constructor( commands: SlashCommandDef[], basePath: string ) {
super( commands, basePath );
this.slashCommands = commands;
}

override async getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
options: { signal: AbortSignal; force?: boolean }
): Promise< AutocompleteSuggestions | null > {
const suggestions = await super.getSuggestions( lines, cursorLine, cursorCol, options );

// Mirror pi-tui's slash-command detection: a `/token` with no space yet.
// Everything else (file paths, @-mentions, argument completion) passes
// through untouched.
const textBeforeCursor = ( lines[ cursorLine ] ?? '' ).slice( 0, cursorCol );
if (
options.force ||
! textBeforeCursor.startsWith( '/' ) ||
textBeforeCursor.includes( ' ' )
) {
return suggestions;
}
const query = textBeforeCursor.slice( 1 ).toLowerCase();
if ( ! query ) {
return suggestions;
}

const existing = new Set( ( suggestions?.items ?? [] ).map( ( item ) => item.value ) );
const descriptionItems = this.slashCommands
.filter(
( command ) =>
! existing.has( command.name ) && command.description.toLowerCase().includes( query )
)
.map( ( command ) => ( {
value: command.name,
label: command.name,
description: command.description,
} ) );
if ( descriptionItems.length === 0 ) {
return suggestions;
}
return {
items: [ ...( suggestions?.items ?? [] ), ...descriptionItems ],
prefix: textBeforeCursor,
};
}
}
54 changes: 54 additions & 0 deletions apps/cli/ai/tests/description-autocomplete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { DescriptionAwareAutocompleteProvider } from 'cli/ai/description-autocomplete';
import type { SlashCommandDef } from 'cli/ai/slash-commands';

const COMMANDS: SlashCommandDef[] = [
{ name: 'exit', description: 'Exit the chat' },
{
name: 'liberate',
description: 'Migrate & rebuild a site from a closed platform',
},
];

const provider = new DescriptionAwareAutocompleteProvider( COMMANDS, process.cwd() );

function suggest( text: string ) {
return provider.getSuggestions( [ text ], 0, text.length, {
signal: new AbortController().signal,
} );
}

describe( 'DescriptionAwareAutocompleteProvider', () => {
it( 'surfaces a command via its description, including partial input', async () => {
for ( const input of [ '/migrate', '/mig' ] ) {
const result = await suggest( input );
expect( result?.items.map( ( item ) => item.value ) ).toEqual( [ 'liberate' ] );
expect( result?.prefix ).toBe( input );
}
} );

it( 'matches descriptions case-insensitively', async () => {
const result = await suggest( '/MIGRATE' );
expect( result?.items.map( ( item ) => item.value ) ).toEqual( [ 'liberate' ] );
} );

it( 'does not duplicate a command matched by both name and description', async () => {
const result = await suggest( '/exit' );
expect( result?.items.filter( ( item ) => item.value === 'exit' ) ).toHaveLength( 1 );
} );

it( 'keeps plain name matching intact', async () => {
const result = await suggest( '/liber' );
expect( result?.items.map( ( item ) => item.value ) ).toEqual( [ 'liberate' ] );
} );

it( 'stays out of argument completion (text after a space)', async () => {
const result = await suggest( '/liberate https://example.com' );
expect( result ).toBeNull();
} );

it( 'returns null when nothing matches', async () => {
const result = await suggest( '/zzz' );
expect( result ).toBeNull();
} );
} );
3 changes: 2 additions & 1 deletion apps/cli/ai/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { getToolDetail, getToolDisplayName, getToolResultPreview } from '@studio
import chalk from '@studio/common/lib/chalk';
import { readAuthToken } from '@studio/common/lib/shared-config';
import { __, _n, sprintf } from '@wordpress/i18n';
import { DescriptionAwareAutocompleteProvider } from 'cli/ai/description-autocomplete';
import { type AiOutputAdapter } from 'cli/ai/output-adapter';
import { AI_PROVIDERS, DEFAULT_AI_PROVIDER, type AiProviderId } from 'cli/ai/providers';
import { getActiveSlashCommands } from 'cli/ai/slash-commands';
Expand Down Expand Up @@ -470,7 +471,7 @@ export class AiChatUI implements AiOutputAdapter {
this.editor = new PromptEditor( this.tui, editorTheme );

this.editor.setAutocompleteProvider(
new CombinedAutocompleteProvider( getActiveSlashCommands(), process.cwd() )
new DescriptionAwareAutocompleteProvider( getActiveSlashCommands(), process.cwd() )
);

this.editor.onSubmit = ( text ) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ describe( 'getSlashCommandMatches', () => {
it( 'filters by case-insensitive substring (including the start)', () => {
const result = getSlashCommandMatches( '/an', null );
expect( result.open ).toBe( true );
// `annotate` starts with it; `rank-me-up` contains it in the middle.
// `annotate` starts with it; `need-for-speed` has it in its description
// ("performance"); `rank-me-up` contains it in the middle.
expect( result.matches.map( ( command ) => command.name ) ).toEqual( [
'annotate',
'need-for-speed',
'rank-me-up',
] );
} );
Expand All @@ -37,6 +39,20 @@ describe( 'getSlashCommandMatches', () => {
expect( result.matches.map( ( command ) => command.name ) ).toEqual( [ 'need-for-speed' ] );
} );

it( 'matches a substring of a command description, including partial input', () => {
for ( const input of [ '/migrate', '/mig' ] ) {
const result = getSlashCommandMatches( input, null );
expect( result.open ).toBe( true );
expect( result.matches.map( ( command ) => command.name ) ).toEqual( [ 'liberate' ] );
}
} );

it( 'matches descriptions case-insensitively', () => {
const result = getSlashCommandMatches( '/MIGRATE', null );
expect( result.open ).toBe( true );
expect( result.matches.map( ( command ) => command.name ) ).toEqual( [ 'liberate' ] );
} );

it( 'opens for a slash token that follows earlier text and a space', () => {
const result = getSlashCommandMatches( 'fix my site /sp', null );
expect( result.open ).toBe( true );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ export interface SlashCommandMatches {
* very start or right after whitespace (e.g. `/`, `/an`, `fix this /sp`), and
* no preview prompt is active. A `/` glued to the end of a word (e.g.
* `path/to`) does not trigger it. The text after the `/` is matched as a
* case-insensitive substring against the available skill command names (so
* `/speed` matches `need-for-speed`). If nothing matches, the popup closes.
* case-insensitive substring against the available skill command names and
* descriptions (so `/speed` matches `need-for-speed` and `/migrate` matches
* `liberate`). If nothing matches, the popup closes.
*/
export function getSlashCommandMatches(
value: string,
Expand All @@ -29,8 +30,10 @@ export function getSlashCommandMatches(
return { open: false, matches: [] };
}
const query = match[ 1 ].toLowerCase();
const matches = AI_SKILL_COMMANDS.filter( ( command ) =>
command.name.toLowerCase().includes( query )
const matches = AI_SKILL_COMMANDS.filter(
( command ) =>
command.name.toLowerCase().includes( query ) ||
command.description.toLowerCase().includes( query )
);
if ( matches.length === 0 ) {
return { open: false, matches: [] };
Expand Down
2 changes: 1 addition & 1 deletion packages/common/ai/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const AI_SKILL_COMMANDS: SkillSlashCommand[] = [
{ name: 'rank-me-up', description: __( 'Run an on-page SEO audit on a site' ) },
{
name: 'liberate',
description: __( 'Import & rebuild a site from a closed platform' ),
description: __( 'Migrate & rebuild a site from a closed platform' ),
},
];

Expand Down