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
30 changes: 30 additions & 0 deletions src/chat-prefix-suggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>

/**
* The slash command and participant suggestions matching what has been typed.
*
* An empty prompt offers everything; anything else is a substring match, so
* typing past the last match legitimately leaves nothing to suggest.
*/
export function prefixesMatching(prefixes: string[], prompt: string): string[] {
const userInput = prompt.trimStart();
if (userInput === '') {
return [...prefixes];
}
return prefixes.filter(prefix => prefix.includes(userInput));
}

/**
* Whether the suggestion popover should act on a keystroke.
*
* The popover is opened by typing `@` or `/` and stays open while the prompt
* is edited, so it can end up with nothing to offer. It renders nothing in
* that state, and it must not claim Enter or Tab either: a popover that
* accepts a suggestion it does not have leaves the message unsendable.
*/
export function isPrefixPopoverUsable(
showPopover: boolean,
suggestions: string[]
): boolean {
return showPopover && suggestions.length > 0;
}
26 changes: 13 additions & 13 deletions src/chat-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ import { TOUR_ANCHOR } from './tour/tour-anchors';
import { TOUR_START_EVENT, TOUR_STOP_EVENT } from './tour/tour-events';
import { hasCompletedTour } from './tour/tour-state';
import { IClaudeSessionInfo } from './api';
import {
isPrefixPopoverUsable,
prefixesMatching
} from './chat-prefix-suggestions';
import {
NOTEBOOK_GENERATION_PROGRESS_EVENT,
type INotebookGenerationProgressDetail
Expand Down Expand Up @@ -2879,6 +2883,8 @@ function SidebarComponent(props: any) {
}
};

const popoverUsable = isPrefixPopoverUsable(showPopover, prefixSuggestions);

const applyPrefixSuggestion = async (prefix: string) => {
let mcpArguments = '';
if (prefix.startsWith('/mcp:')) {
Expand Down Expand Up @@ -3334,14 +3340,7 @@ function SidebarComponent(props: any) {
);

const filterPrefixSuggestions = (prmpt: string) => {
const userInput = prmpt.trimStart();
if (userInput === '') {
setPrefixSuggestions(originalPrefixes);
} else {
setPrefixSuggestions(
originalPrefixes.filter(prefix => prefix.includes(userInput))
);
}
setPrefixSuggestions(prefixesMatching(originalPrefixes, prmpt));
};

const resetPrefixSuggestions = () => {
Expand Down Expand Up @@ -3408,15 +3407,16 @@ function SidebarComponent(props: any) {
}
event.stopPropagation();
event.preventDefault();
if (showPopover) {
if (popoverUsable) {
applyPrefixSuggestion(prefixSuggestions[selectedPrefixSuggestionIndex]);
return;
}

setShowPopover(false);
setSelectedPrefixSuggestionIndex(0);
handleSubmitStopChatButtonClick();
} else if (event.key === 'Tab') {
if (showPopover) {
if (popoverUsable) {
event.stopPropagation();
event.preventDefault();
applyPrefixSuggestion(prefixSuggestions[selectedPrefixSuggestionIndex]);
Expand All @@ -3433,7 +3433,7 @@ function SidebarComponent(props: any) {
event.stopPropagation();
event.preventDefault();

if (showPopover) {
if (popoverUsable) {
setSelectedPrefixSuggestionIndex(
(selectedPrefixSuggestionIndex - 1 + prefixSuggestions.length) %
prefixSuggestions.length
Expand Down Expand Up @@ -3464,7 +3464,7 @@ function SidebarComponent(props: any) {
event.stopPropagation();
event.preventDefault();

if (showPopover) {
if (popoverUsable) {
setSelectedPrefixSuggestionIndex(
(selectedPrefixSuggestionIndex + 1 + prefixSuggestions.length) %
prefixSuggestions.length
Expand Down Expand Up @@ -4535,7 +4535,7 @@ function SidebarComponent(props: any) {
</button>
</div>
</div>
{showPopover && prefixSuggestions.length > 0 && (
{popoverUsable && (
<div className="user-input-autocomplete" ref={autocompleteRef}>
{prefixSuggestions.map((prefix, index) => (
<div
Expand Down
55 changes: 55 additions & 0 deletions tests/ts/chat-prefix-suggestions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>

// A prompt beginning with @ or / opens the suggestion popover, and the
// popover used to keep claiming Enter after the prompt was typed past every
// match, so such a message could not be sent from the keyboard at all.
import {
isPrefixPopoverUsable,
prefixesMatching
} from '../../src/chat-prefix-suggestions';

const PREFIXES = ['@mcp', '/clear', '/newNotebook', '/newPythonFile'];

describe('prefixesMatching', () => {
it('offers everything for an empty prompt', () => {
expect(prefixesMatching(PREFIXES, '')).toEqual(PREFIXES);
expect(prefixesMatching(PREFIXES, ' ')).toEqual(PREFIXES);
});

it('narrows to substring matches', () => {
expect(prefixesMatching(PREFIXES, '/new')).toEqual([
'/newNotebook',
'/newPythonFile'
]);
expect(prefixesMatching(PREFIXES, '@')).toEqual(['@mcp']);
});

it('offers nothing once the prompt is typed past every match', () => {
expect(prefixesMatching(PREFIXES, '@Ada please review this')).toEqual([]);
expect(prefixesMatching(PREFIXES, '/usr/bin/python is missing')).toEqual(
[]
);
});

it('does not mutate the list it was given', () => {
const original = [...PREFIXES];
prefixesMatching(PREFIXES, '').push('/mutated');
expect(PREFIXES).toEqual(original);
});
});

describe('isPrefixPopoverUsable', () => {
it('is false with nothing to suggest, even while open', () => {
// The case behind the bug: the popover renders nothing here, so it must
// not claim Enter or Tab either.
expect(isPrefixPopoverUsable(true, [])).toBe(false);
});

it('is true while it has something to offer', () => {
expect(isPrefixPopoverUsable(true, ['/clear'])).toBe(true);
});

it('is false when closed', () => {
expect(isPrefixPopoverUsable(false, ['/clear'])).toBe(false);
});
});
Loading