Skip to content
Open
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
84 changes: 83 additions & 1 deletion apps/cli/ai/tests/ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,82 @@ vi.mock( 'cli/lib/site-utils', () => ( {
isSiteRunning: vi.fn(),
} ) );

describe( 'AiChatUI.updateAutocompleteBasePath', () => {
const LAUNCH_CWD = '/launch/cwd';

function makeUi() {
const calls: Array< { basePath: string | undefined } > = [];
const ui = Object.create( AiChatUI.prototype ) as {
updateAutocompleteBasePath: () => void;
[ key: string ]: unknown;
};
ui.initialCwd = LAUNCH_CWD;
ui._activeSite = null;
ui.autocompleteBasePath = null;
ui.editor = {
activeSiteName: null,
setAutocompleteProvider: ( provider: { basePath?: string } ) => {
calls.push( { basePath: provider.basePath } );
},
};
return { ui, calls };
}

it( 'scopes @-mention completion to the active local site path', () => {
const { ui, calls } = makeUi();
ui._activeSite = {
name: 'my-site',
path: '/Users/test/Studio/my-site',
running: true,
};
ui.updateAutocompleteBasePath();
expect( calls ).toHaveLength( 1 );
expect( calls[ 0 ].basePath ).toBe( '/Users/test/Studio/my-site' );
} );

it( 'falls back to the launch cwd when no site is active', () => {
const { ui, calls } = makeUi();
ui.updateAutocompleteBasePath();
expect( calls[ 0 ].basePath ).toBe( LAUNCH_CWD );
} );

it( 'falls back to the launch cwd for remote sites (no local files to complete)', () => {
const { ui, calls } = makeUi();
ui._activeSite = {
name: 'remote-site',
path: '',
remote: true,
running: true,
};
ui.updateAutocompleteBasePath();
expect( calls[ 0 ].basePath ).toBe( LAUNCH_CWD );
} );

it( 'updates the basePath when the active site changes', () => {
const { ui, calls } = makeUi();
ui._activeSite = { name: 'a', path: '/Users/test/Studio/a', running: true };
ui.updateAutocompleteBasePath();
ui._activeSite = { name: 'b', path: '/Users/test/Studio/b', running: true };
ui.updateAutocompleteBasePath();
ui._activeSite = null;
ui.updateAutocompleteBasePath();
expect( calls.map( ( c ) => c.basePath ) ).toEqual( [
'/Users/test/Studio/a',
'/Users/test/Studio/b',
LAUNCH_CWD,
] );
} );

it( 'skips installing a fresh provider when the basePath is unchanged', () => {
const { ui, calls } = makeUi();
ui._activeSite = { name: 'a', path: '/Users/test/Studio/a', running: true };
ui.updateAutocompleteBasePath();
ui.updateAutocompleteBasePath();
ui.updateAutocompleteBasePath();
expect( calls ).toHaveLength( 1 );
} );
} );

describe( 'AiChatUI.openActiveSiteInBrowser', () => {
beforeEach( () => {
vi.clearAllMocks();
Expand Down Expand Up @@ -87,7 +163,13 @@ describe( 'AiChatUI auto site selection', () => {
};
ui._activeSite = null;
ui._activeSiteData = null;
ui.editor = { activeSiteName: null, invalidate: vi.fn() };
ui.initialCwd = '/launch/cwd';
ui.autocompleteBasePath = null;
ui.editor = {
activeSiteName: null,
invalidate: vi.fn(),
setAutocompleteProvider: vi.fn(),
};
ui.messages = { addChild: vi.fn() };
ui.tui = { requestRender: vi.fn() };
ui.siteSelectedCallback = vi.fn();
Expand Down
30 changes: 23 additions & 7 deletions apps/cli/ai/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,9 @@ export class AiChatUI implements AiOutputAdapter {
private _inAgentTurn = false;
private _activeSiteData: SiteData | null = null;
private siteSelectedCallback: ( ( site: SiteInfo ) => void ) | null = null;
// `@`-mention fallback when no local site is active.
private readonly initialCwd = process.cwd();
private autocompleteBasePath: string | null = null;
private replayMode = false;
private replayTimestampMs: number | null = null;
private pendingToolCalls = new Map<
Expand Down Expand Up @@ -452,12 +455,29 @@ export class AiChatUI implements AiOutputAdapter {
set activeSite( site: SiteInfo | null ) {
this._activeSite = site;
this.editor.activeSiteName = site?.name ?? null;
this.updateAutocompleteBasePath();
}

private refreshPromptChrome(): void {
this.editor.invalidate();
}

// pi-tui's `CombinedAutocompleteProvider` accepts its basePath only at
// construction, so a scope change requires a fresh provider. Remote sites
// have no local files to complete against, so they fall through to the
// launch cwd.
private updateAutocompleteBasePath(): void {
const basePath =
this._activeSite && ! this._activeSite.remote ? this._activeSite.path : this.initialCwd;
if ( basePath === this.autocompleteBasePath ) {
return;
}
this.autocompleteBasePath = basePath;
this.editor.setAutocompleteProvider(
new CombinedAutocompleteProvider( AI_CHAT_SLASH_COMMANDS, basePath )
);
}

set onSiteSelected( fn: ( ( site: SiteInfo ) => void ) | null ) {
this.siteSelectedCallback = fn;
}
Expand Down Expand Up @@ -566,9 +586,7 @@ export class AiChatUI implements AiOutputAdapter {

this.editor = new PromptEditor( this.tui, editorTheme );

this.editor.setAutocompleteProvider(
new CombinedAutocompleteProvider( AI_CHAT_SLASH_COMMANDS, process.cwd() )
);
this.updateAutocompleteBasePath();

this.editor.onSubmit = ( text ) => {
const trimmed = text.trim();
Expand Down Expand Up @@ -910,8 +928,7 @@ export class AiChatUI implements AiOutputAdapter {

setActiveSite( site: SiteInfo, options: { announce?: boolean; emitEvent?: boolean } = {} ): void {
const { announce = true, emitEvent = true } = options;
this._activeSite = site;
this.editor.activeSiteName = site.name;
this.activeSite = site;
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.

Is this like an unrelated cleanup or something?

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.

Adjacent dedup, not random — it's part of "funnel site mutations through the activeSite setter" in this commit. The setter already does _activeSite = site + editor.activeSiteName = … + updateAutocompleteBasePath(), so setActiveSite/clearActiveSite were duplicating the first two and the original PR added the third in three places. Routing them through the setter means a single source of truth for "what happens when activeSite changes" — future code paths that mutate it can't forget to update the basePath. Happy to split into its own commit if you'd rather.

this.refreshPromptChrome();
const label = site.remote
? sprintf(
Expand All @@ -934,9 +951,8 @@ export class AiChatUI implements AiOutputAdapter {
}

private clearActiveSite(): void {
this._activeSite = null;
this.activeSite = null;
this._activeSiteData = null;
this.editor.activeSiteName = null;
this.refreshPromptChrome();
this.messages.addChild( new Text( chalk.dim( __( ' ✻ Site deselected' ) ) + '\n', 0, 0 ) );
this.tui.requestRender();
Expand Down
Loading