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
32 changes: 32 additions & 0 deletions apps/cli/ai/option-picker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { wrapTextWithAnsi, type SelectItem } from '@earendil-works/pi-tui';
import chalk from '@studio/common/lib/chalk';

const DESCRIPTION_INDENT = ' '; // aligns descriptions under the numbered labels ("→ 1. ")
const MIN_DESCRIPTION_WIDTH = 10;

/**
* Multi-line option rows for AskUserQuestion: full label with the wrapped
* description indented below — no truncation, newlines preserved.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the name of the function is pretty self-explanatory so I would remove this or maybe just shorten to mention the gist. Not a strong preference if you find that keeping this is helpful

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I shortened it

export function buildOptionPickerLines(
items: SelectItem[],
selectedValue: string | undefined,
width: number
): string[] {
const descriptionWidth = Math.max( MIN_DESCRIPTION_WIDTH, width - DESCRIPTION_INDENT.length - 2 );
const lines: string[] = [];

for ( const item of items ) {
const isSelected = item.value === selectedValue;
item.label.split( '\n' ).forEach( ( labelLine, index ) => {
const marker = index === 0 && isSelected ? '→ ' : ' ';
lines.push( isSelected ? chalk.blue( marker + labelLine ) : marker + labelLine );
} );
if ( item.description ) {
for ( const descriptionLine of wrapTextWithAnsi( item.description, descriptionWidth ) ) {
lines.push( chalk.dim( DESCRIPTION_INDENT + descriptionLine ) );
}
}
}
return lines;
}
75 changes: 75 additions & 0 deletions apps/cli/ai/tests/option-picker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { buildOptionPickerLines } from '../option-picker';

// eslint-disable-next-line no-control-regex
const stripAnsi = ( text: string ) => text.replace( /\u001b\[[0-9;]*m/g, '' );

const ITEMS = [
{
value: 'Blocks + products',
label: '1. Blocks + products (Recommended)',
description: 'Native, fully editable WordPress blocks. Best launchpad for a redesign.',
},
{
value: 'Theme replication',
label: '2. Theme replication',
description: 'Pixel-accurate replica of the source.',
},
{ value: '__other__', label: 'Other (type my own)' },
];

describe( 'buildOptionPickerLines', () => {
it( 'renders each option label followed by its indented description', () => {
const lines = buildOptionPickerLines( ITEMS, 'Blocks + products', 100 ).map( stripAnsi );
expect( lines ).toEqual( [
'→ 1. Blocks + products (Recommended)',
' Native, fully editable WordPress blocks. Best launchpad for a redesign.',
' 2. Theme replication',
' Pixel-accurate replica of the source.',
' Other (type my own)',
] );
} );

it( 'marks the selected item with an arrow prefix', () => {
const lines = buildOptionPickerLines( ITEMS, 'Theme replication', 100 ).map( stripAnsi );
expect( lines[ 0 ] ).toBe( ' 1. Blocks + products (Recommended)' );
expect( lines[ 2 ] ).toBe( '→ 2. Theme replication' );
} );

it( 'wraps long descriptions across multiple indented lines without truncation', () => {
const lines = buildOptionPickerLines( ITEMS, undefined, 40 ).map( stripAnsi );
const descriptionLines = lines.filter( ( line ) => line.startsWith( ' ' ) );
expect( descriptionLines.length ).toBeGreaterThan( 2 );
expect( descriptionLines.join( ' ' ).replace( /\s+/g, ' ' ).trim() ).toContain(
'Best launchpad for a redesign.'
);
for ( const line of lines ) {
expect( line.length ).toBeLessThanOrEqual( 40 );
}
} );

it( 'preserves explicit newlines in labels and descriptions', () => {
const lines = buildOptionPickerLines(
[
{
value: 'a',
label: '1. First line\nsecond label line',
description: 'Para one.\nPara two.',
},
],
'a',
80
).map( stripAnsi );
expect( lines ).toEqual( [
'→ 1. First line',
' second label line',
' Para one.',
' Para two.',
] );
} );

it( 'keeps the descriptionless last item on the final line for the Other inline input', () => {
const lines = buildOptionPickerLines( ITEMS, '__other__', 100 ).map( stripAnsi );
expect( lines[ lines.length - 1 ] ).toBe( '→ Other (type my own)' );
} );
} );
16 changes: 13 additions & 3 deletions apps/cli/ai/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
DescriptionAwareAutocompleteProvider,
dimUnhighlighted,
} from 'cli/ai/description-autocomplete';
import { buildOptionPickerLines } from 'cli/ai/option-picker';
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 @@ -329,6 +330,7 @@ export class AiChatUI implements AiOutputAdapter {
private optionPickerHasFreeForm = false;
private optionPickerItemCount = 0;
private optionPickerInput: Input | null = null;
private optionPickerItems: SelectItem[] = [];
private static readonly OTHER_VALUE = '__other__';
private static readonly OPTION_PICKER_THEME: SelectListTheme = {
selectedPrefix: ( text: string ) => chalk.blue( text ),
Expand Down Expand Up @@ -1035,9 +1037,16 @@ export class AiChatUI implements AiOutputAdapter {
this.optionPickerContainer.clear();

const width = ( process.stdout.columns ?? 80 ) - 1;
const lines = this.optionPickerSelectList.render( width );
// Custom multi-line rendering (full labels + wrapped descriptions);
// SelectList is kept only for keyboard navigation and selection state.
const lines = buildOptionPickerLines(
this.optionPickerItems,
this.optionPickerSelectList.getSelectedItem()?.value,
width
);

// When "Other" is active, replace the last line with the inline input
// ("Other" is always the last item and has no description lines).
if ( this.optionPickerOtherActive && this.optionPickerInput && lines.length > 0 ) {
const inputText = this.optionPickerInput.getValue();
const cursor = chalk.inverse( ' ' );
Expand Down Expand Up @@ -1082,6 +1091,7 @@ export class AiChatUI implements AiOutputAdapter {
this.optionPickerSelectList = null;
this.optionPickerHasFreeForm = false;
this.optionPickerItemCount = 0;
this.optionPickerItems = [];
this.deactivateOptionPickerOther();
this.tui.requestRender();
}
Expand Down Expand Up @@ -1985,6 +1995,7 @@ export class AiChatUI implements AiOutputAdapter {
} );
}

this.optionPickerItems = selectItems;
this.optionPickerItemCount = selectItems.length;
const selectList = new SelectList(
selectItems,
Expand All @@ -1996,8 +2007,7 @@ export class AiChatUI implements AiOutputAdapter {
this.optionPickerVisible = true;
this.optionPickerContainer = new Container();
this.tui.addChild( this.optionPickerContainer );
this.optionPickerContainer.addChild( this.optionPickerSelectList );
this.tui.requestRender();
this.renderOptionPicker();

const selected = await new Promise< string >( ( resolve ) => {
this.optionPickerResolve = resolve;
Expand Down
Loading