Skip to content

Commit 988f1f3

Browse files
Define a new framework for chat commands (#161)
* minimal working example * Automatic application of license header * move use-chat-commands.ts * revise ChatCommand and IChatCommandProvider types * simplify code by using functions that act on words * clean up examples * Automatic application of license header * do not call handleChatCommand() if replaceWith is set * fix bugs identified by @brichet * update regexes and add a space after emojis * close command menu on empty input * move registry to jupyter-chat * include tabs and newlines as word boundaries * remove empty registry.ts file * remove demo slash command plugin * remove old autocompletion tests --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 73512c1 commit 988f1f3

File tree

14 files changed

+549
-449
lines changed

14 files changed

+549
-449
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/*
2+
* Copyright (c) Jupyter Development Team.
3+
* Distributed under the terms of the Modified BSD License.
4+
*/
5+
6+
export * from './types';
7+
export * from './registry';
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright (c) Jupyter Development Team.
3+
* Distributed under the terms of the Modified BSD License.
4+
*/
5+
6+
import { Token } from '@lumino/coreutils';
7+
import { ChatCommand, IChatCommandProvider } from './types';
8+
9+
/**
10+
* Interface of a chat command registry, which tracks a list of chat command
11+
* providers. Providers provide a list of commands given a user's partial input,
12+
* and define how commands are handled when accepted in the chat commands menu.
13+
*/
14+
export interface IChatCommandRegistry {
15+
addProvider(provider: IChatCommandProvider): void;
16+
getProviders(): IChatCommandProvider[];
17+
18+
/**
19+
* Handles a chat command by calling `handleChatCommand()` on the provider
20+
* corresponding to this chat command.
21+
*/
22+
handleChatCommand(
23+
command: ChatCommand,
24+
currentWord: string,
25+
replaceCurrentWord: (newWord: string) => void
26+
): void;
27+
}
28+
29+
/**
30+
* Default chat command registry implementation.
31+
*/
32+
export class ChatCommandRegistry implements IChatCommandRegistry {
33+
constructor() {
34+
this._providers = new Map<string, IChatCommandProvider>();
35+
}
36+
37+
addProvider(provider: IChatCommandProvider): void {
38+
this._providers.set(provider.id, provider);
39+
}
40+
41+
getProviders(): IChatCommandProvider[] {
42+
return Array.from(this._providers.values());
43+
}
44+
45+
handleChatCommand(
46+
command: ChatCommand,
47+
currentWord: string,
48+
replaceCurrentWord: (newWord: string) => void
49+
) {
50+
const provider = this._providers.get(command.providerId);
51+
if (!provider) {
52+
console.error(
53+
'Error in handling chat command: No command provider has an ID of ' +
54+
command.providerId
55+
);
56+
return;
57+
}
58+
59+
provider.handleChatCommand(command, currentWord, replaceCurrentWord);
60+
}
61+
62+
private _providers: Map<string, IChatCommandProvider>;
63+
}
64+
65+
export const IChatCommandRegistry = new Token<IChatCommandRegistry>(
66+
'@jupyter/chat:IChatCommandRegistry'
67+
);
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright (c) Jupyter Development Team.
3+
* Distributed under the terms of the Modified BSD License.
4+
*/
5+
6+
import { LabIcon } from '@jupyterlab/ui-components';
7+
8+
export type ChatCommand = {
9+
/**
10+
* The name of the command. This defines what the user should type in the
11+
* input to have the command appear in the chat commands menu.
12+
*/
13+
name: string;
14+
15+
/**
16+
* ID of the provider the command originated from.
17+
*/
18+
providerId: string;
19+
20+
/**
21+
* If set, this will be rendered as the icon for the command in the chat
22+
* commands menu. Jupyter Chat will choose a default if this is unset.
23+
*/
24+
icon?: LabIcon;
25+
26+
/**
27+
* If set, this will be rendered as the description for the command in the
28+
* chat commands menu. Jupyter Chat will choose a default if this is unset.
29+
*/
30+
description?: string;
31+
32+
/**
33+
* If set, Jupyter Chat will replace the current word with this string after
34+
* the command is run from the chat commands menu.
35+
*
36+
* If all commands from a provider have this property set, then
37+
* `handleChatCommands()` can just return on the first line.
38+
*/
39+
replaceWith?: string;
40+
};
41+
42+
/**
43+
* Interface of a command provider.
44+
*/
45+
export interface IChatCommandProvider {
46+
/**
47+
* ID of this command provider.
48+
*/
49+
id: string;
50+
51+
/**
52+
* Async function which accepts the current word and returns a list of
53+
* valid chat commands that match the current word. The current word is
54+
* space-separated word at the user's cursor.
55+
*
56+
* TODO: Pass a ChatModel/InputModel instance here to give the command access
57+
* to more than the current word.
58+
*/
59+
getChatCommands(currentWord: string): Promise<ChatCommand[]>;
60+
61+
/**
62+
* Function called when a chat command is run by the user through the chat
63+
* commands menu.
64+
*
65+
* TODO: Pass a ChatModel/InputModel instance here to provide a function to
66+
* replace the current word.
67+
*/
68+
handleChatCommand(
69+
command: ChatCommand,
70+
currentWord: string,
71+
replaceCurrentWord: (newWord: string) => void
72+
): Promise<void>;
73+
}

packages/jupyter-chat/src/components/chat-input.tsx

Lines changed: 51 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,24 @@ import { CancelButton } from './input/cancel-button';
1919
import { SendButton } from './input/send-button';
2020
import { IChatModel } from '../model';
2121
import { IAutocompletionRegistry } from '../registry';
22-
import {
23-
AutocompleteCommand,
24-
IAutocompletionCommandsProps,
25-
IConfig,
26-
Selection
27-
} from '../types';
22+
import { IConfig, Selection } from '../types';
23+
import { useChatCommands } from './input/use-chat-commands';
24+
import { IChatCommandRegistry } from '../chat-commands';
2825

2926
const INPUT_BOX_CLASS = 'jp-chat-input-container';
3027

3128
export function ChatInput(props: ChatInput.IProps): JSX.Element {
32-
const { autocompletionName, autocompletionRegistry, model } = props;
33-
const autocompletion = useRef<IAutocompletionCommandsProps>();
29+
const { model } = props;
3430
const [input, setInput] = useState<string>(props.value || '');
31+
const inputRef = useRef<HTMLInputElement>();
32+
33+
const chatCommands = useChatCommands(
34+
input,
35+
setInput,
36+
inputRef,
37+
props.chatCommandRegistry
38+
);
39+
3540
const [sendWithShiftEnter, setSendWithShiftEnter] = useState<boolean>(
3641
model.config.sendWithShiftEnter ?? false
3742
);
@@ -46,9 +51,6 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
4651
hideIncludeSelection = true;
4752
}
4853

49-
// store reference to the input element to enable focusing it easily
50-
const inputRef = useRef<HTMLInputElement>();
51-
5254
useEffect(() => {
5355
const configChanged = (_: IChatModel, config: IConfig) => {
5456
setSendWithShiftEnter(config.sendWithShiftEnter ?? false);
@@ -69,87 +71,62 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
6971
};
7072
}, [model]);
7173

72-
// The autocomplete commands options.
73-
const [commandOptions, setCommandOptions] = useState<AutocompleteCommand[]>(
74-
[]
75-
);
76-
// whether any option is highlighted in the slash command autocomplete
77-
const [highlighted, setHighlighted] = useState<boolean>(false);
78-
// controls whether the slash command autocomplete is open
79-
const [open, setOpen] = useState<boolean>(false);
80-
8174
const inputExists = !!input.trim();
8275

8376
/**
84-
* Effect: fetch the list of available autocomplete commands.
85-
*/
86-
useEffect(() => {
87-
if (autocompletionRegistry === undefined) {
88-
return;
89-
}
90-
autocompletion.current = autocompletionName
91-
? autocompletionRegistry.get(autocompletionName)
92-
: autocompletionRegistry.getDefaultCompletion();
93-
94-
if (autocompletion.current === undefined) {
95-
return;
96-
}
97-
98-
if (Array.isArray(autocompletion.current.commands)) {
99-
setCommandOptions(autocompletion.current.commands);
100-
} else if (typeof autocompletion.current.commands === 'function') {
101-
autocompletion.current
102-
.commands()
103-
.then((commands: AutocompleteCommand[]) => {
104-
setCommandOptions(commands);
105-
});
106-
}
107-
}, []);
108-
109-
/**
110-
* Effect: Open the autocomplete when the user types the 'opener' string into an
111-
* empty chat input. Close the autocomplete and reset the last selected value when
112-
* the user clears the chat input.
77+
* `handleKeyDown()`: callback invoked when the user presses any key in the
78+
* `TextField` component. This is used to send the message when a user presses
79+
* "Enter". This also handles many of the edge cases in the MUI Autocomplete
80+
* component.
11381
*/
114-
useEffect(() => {
115-
if (!autocompletion.current?.opener) {
116-
return;
117-
}
118-
119-
if (input === autocompletion.current?.opener) {
120-
setOpen(true);
121-
return;
122-
}
123-
124-
if (input === '') {
125-
setOpen(false);
126-
return;
127-
}
128-
}, [input]);
129-
13082
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
131-
if (['ArrowDown', 'ArrowUp'].includes(event.key) && !open) {
83+
/**
84+
* IMPORTANT: This statement ensures that arrow keys can be used to navigate
85+
* the multiline input when the chat commands menu is closed.
86+
*/
87+
if (
88+
['ArrowDown', 'ArrowUp'].includes(event.key) &&
89+
!chatCommands.menu.open
90+
) {
13291
event.stopPropagation();
13392
return;
13493
}
13594

95+
// remainder of this function only handles the "Enter" key.
13696
if (event.key !== 'Enter') {
13797
return;
13898
}
13999

140-
// Do not send the message if the user was selecting a suggested command from the
141-
// Autocomplete component.
142-
if (highlighted) {
100+
/**
101+
* IMPORTANT: This statement ensures that when the chat commands menu is
102+
* open with a highlighted command, the "Enter" key should run that command
103+
* instead of sending the message.
104+
*
105+
* This is done by returning early and letting the event propagate to the
106+
* `Autocomplete` component.
107+
*/
108+
if (chatCommands.menu.highlighted) {
143109
return;
144110
}
145111

112+
// remainder of this function only handles the "Enter" key pressed while the
113+
// commands menu is closed.
114+
/**
115+
* IMPORTANT: This ensures that when the "Enter" key is pressed with the
116+
* commands menu closed, the event is not propagated up to the
117+
* `Autocomplete` component. Without this, `Autocomplete.onChange()` gets
118+
* called with an invalid `string` instead of a `ChatCommand`.
119+
*/
120+
event.stopPropagation();
121+
146122
// Do not send empty messages, and avoid adding new line in empty message.
147123
if (!inputExists) {
148124
event.stopPropagation();
149125
event.preventDefault();
150126
return;
151127
}
152128

129+
// Finally, send the message when all other conditions are met.
153130
if (
154131
(sendWithShiftEnter && event.shiftKey) ||
155132
(!sendWithShiftEnter && !event.shiftKey)
@@ -201,11 +178,7 @@ ${selection.source}
201178
return (
202179
<Box sx={props.sx} className={clsx(INPUT_BOX_CLASS)}>
203180
<Autocomplete
204-
options={commandOptions}
205-
value={props.value}
206-
open={open}
207-
autoHighlight
208-
freeSolo
181+
{...chatCommands.autocompleteProps}
209182
// ensure the autocomplete popup always renders on top
210183
componentsProps={{
211184
popper: {
@@ -255,38 +228,13 @@ ${selection.source}
255228
helperText={input.length > 2 ? helperText : ' '}
256229
/>
257230
)}
258-
{...autocompletion.current?.props}
259231
inputValue={input}
260232
onInputChange={(_, newValue: string) => {
261233
setInput(newValue);
262234
if (typingNotification && model.inputChanged) {
263235
model.inputChanged(newValue);
264236
}
265237
}}
266-
onHighlightChange={
267-
/**
268-
* On highlight change: set `highlighted` to whether an option is
269-
* highlighted by the user.
270-
*
271-
* This isn't called when an option is selected for some reason, so we
272-
* need to call `setHighlighted(false)` in `onClose()`.
273-
*/
274-
(_, highlightedOption) => {
275-
setHighlighted(!!highlightedOption);
276-
}
277-
}
278-
onClose={
279-
/**
280-
* On close: set `highlighted` to `false` and close the popup by
281-
* setting `open` to `false`.
282-
*/
283-
() => {
284-
setHighlighted(false);
285-
setOpen(false);
286-
}
287-
}
288-
// hide default extra right padding in the text field
289-
disableClearable
290238
/>
291239
</Box>
292240
);
@@ -332,5 +280,9 @@ export namespace ChatInput {
332280
* Autocompletion name.
333281
*/
334282
autocompletionName?: string;
283+
/**
284+
* Chat command registry.
285+
*/
286+
chatCommandRegistry?: IChatCommandRegistry;
335287
}
336288
}

0 commit comments

Comments
 (0)