Skip to content

Commit 07c8322

Browse files
authored
Run chat commands on message submission (#231)
* run chat commands on message submission & fix mention commands * replace commands immediately on type * add spaceOnAccept property * fix multiple mentions * hide users already mentioned * simplify replacement logic * add more info to spaceOnAccept docstring * remove optional colon in mention regex
1 parent 8253718 commit 07c8322

File tree

7 files changed

+160
-86
lines changed

7 files changed

+160
-86
lines changed

packages/jupyter-chat/src/components/input/buttons/send-button.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const SEND_INCLUDE_LI_CLASS = 'jp-chat-send-include';
2323
export function SendButton(
2424
props: InputToolbarRegistry.IToolbarItemProps
2525
): JSX.Element {
26-
const { model } = props;
26+
const { model, chatCommandRegistry } = props;
2727
const { activeCellManager, selectionWatcher } = model;
2828
const hideIncludeSelection = !activeCellManager || !selectionWatcher;
2929

@@ -98,9 +98,17 @@ export function SendButton(
9898
};
9999
}, [activeCellManager, selectionWatcher]);
100100

101-
function sendWithSelection() {
101+
async function send() {
102+
await chatCommandRegistry?.onSubmit(model);
103+
model.send(model.value);
104+
}
105+
106+
async function sendWithSelection() {
102107
let source = '';
103108

109+
// Run all chat command providers
110+
await chatCommandRegistry?.onSubmit(model);
111+
104112
if (selectionWatcher?.selection) {
105113
// Append the selected text if exists.
106114
source = selectionWatcher.selection.text;
@@ -125,7 +133,7 @@ ${source}
125133
return (
126134
<>
127135
<TooltippedButton
128-
onClick={() => model.send(model.value)}
136+
onClick={send}
129137
disabled={disabled}
130138
tooltip={tooltip}
131139
buttonProps={{

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
105105
* "Enter". This also handles many of the edge cases in the MUI Autocomplete
106106
* component.
107107
*/
108-
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
108+
async function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
109109
/**
110110
* IMPORTANT: This statement ensures that arrow keys can be used to navigate
111111
* the multiline input when the chat commands menu is closed.
@@ -157,6 +157,8 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
157157
(sendWithShiftEnter && event.shiftKey) ||
158158
(!sendWithShiftEnter && !event.shiftKey)
159159
) {
160+
// Run all command providers
161+
await props.chatCommandRegistry?.onSubmit(model);
160162
model.send(input);
161163
event.stopPropagation();
162164
event.preventDefault();
@@ -218,7 +220,10 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
218220
endAdornment: (
219221
<InputAdornment position="end" className={INPUT_TOOLBAR_CLASS}>
220222
{toolbarElements.map(item => (
221-
<item.element model={model} />
223+
<item.element
224+
model={model}
225+
chatCommandRegistry={props.chatCommandRegistry}
226+
/>
222227
))}
223228
</InputAdornment>
224229
)

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as React from 'react';
77
import { AttachButton, CancelButton, SendButton } from './buttons';
88
import { IInputModel } from '../../input-model';
99
import { ISignal, Signal } from '@lumino/signaling';
10+
import { IChatCommandRegistry } from '../../registers';
1011

1112
/**
1213
* The toolbar registry interface.
@@ -137,6 +138,11 @@ export namespace InputToolbarRegistry {
137138
* The input model of the input component including the button.
138139
*/
139140
model: IInputModel;
141+
/**
142+
* Chat command registry. Should be used by the "Send" button to run
143+
* `onSubmit()` on all command providers before sending the message.
144+
*/
145+
chatCommandRegistry?: IChatCommandRegistry;
140146
}
141147

142148
/**

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

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export function useChatCommands(
4545
const [commands, setCommands] = useState<ChatCommand[]>([]);
4646

4747
useEffect(() => {
48+
/**
49+
* Callback that runs whenever the current word changes.
50+
*/
4851
async function getCommands(_: IInputModel, currentWord: string | null) {
4952
const providers = chatCommandRegistry?.getProviders();
5053
if (!providers) {
@@ -58,12 +61,12 @@ export function useChatCommands(
5861
return;
5962
}
6063

61-
let newCommands: ChatCommand[] = [];
64+
let commandCompletions: ChatCommand[] = [];
6265
for (const provider of providers) {
6366
// TODO: optimize performance when this method is truly async
6467
try {
65-
newCommands = newCommands.concat(
66-
await provider.getChatCommands(inputModel)
68+
commandCompletions = commandCompletions.concat(
69+
await provider.listCommandCompletions(inputModel)
6770
);
6871
} catch (e) {
6972
console.error(
@@ -72,10 +75,23 @@ export function useChatCommands(
7275
);
7376
}
7477
}
75-
if (newCommands) {
76-
setOpen(true);
78+
79+
// Immediately replace the current word if it exactly matches one command
80+
// and 'replaceWith' is set.
81+
if (
82+
commandCompletions.length === 1 &&
83+
commandCompletions[0].name === inputModel.currentWord &&
84+
commandCompletions[0].replaceWith !== undefined
85+
) {
86+
const replacement = commandCompletions[0].replaceWith;
87+
inputModel.replaceCurrentWord(replacement);
88+
return;
7789
}
78-
setCommands(newCommands);
90+
91+
// Otherwise, open/close the menu based on the presence of command
92+
// completions and set the menu entries.
93+
setOpen(!!commandCompletions.length);
94+
setCommands(commandCompletions);
7995
}
8096

8197
inputModel.currentWordChanged.connect(getCommands);
@@ -87,7 +103,8 @@ export function useChatCommands(
87103

88104
/**
89105
* onChange(): the callback invoked when a command is selected from the chat
90-
* commands menu by the user.
106+
* commands menu. When a command `cmd` is selected, this function replaces the
107+
* current word with `cmd.replaceWith` if set, `cmd.name` otherwise.
91108
*/
92109
const onChange: AutocompleteProps['onChange'] = (
93110
e: unknown,
@@ -109,14 +126,12 @@ export function useChatCommands(
109126
return;
110127
}
111128

112-
// if replaceWith is set, handle the command immediately
113-
if (command.replaceWith) {
114-
inputModel.replaceCurrentWord(command.replaceWith);
115-
return;
129+
let replacement =
130+
command.replaceWith === undefined ? command.name : command.replaceWith;
131+
if (command.spaceOnAccept) {
132+
replacement += ' ';
116133
}
117-
118-
// otherwise, defer handling to the command provider
119-
chatCommandRegistry.handleChatCommand(command, inputModel);
134+
inputModel.replaceCurrentWord(replacement);
120135
};
121136

122137
return {

packages/jupyter-chat/src/registers/chat-commands.ts

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,25 @@ export const IChatCommandRegistry = new Token<IChatCommandRegistry>(
2121
* and define how commands are handled when accepted in the chat commands menu.
2222
*/
2323
export interface IChatCommandRegistry {
24+
/**
25+
* Adds a chat command provider to the registry.
26+
*/
2427
addProvider(provider: IChatCommandProvider): void;
28+
29+
/**
30+
* Lists all chat command providers previously added via `addProvider()`.
31+
*/
2532
getProviders(): IChatCommandProvider[];
2633

2734
/**
28-
* Handles a chat command by calling `handleChatCommand()` on the provider
29-
* corresponding to this chat command.
35+
* Calls `onSubmit()` on each command provider in serial. Each command
36+
* provider's `onSubmit()` method is responsible for checking the entire input
37+
* for command calls and handling them accordingly.
38+
*
39+
* This method is called by the application after the user presses the "Send"
40+
* button but before the message is sent to server.
3041
*/
31-
handleChatCommand(command: ChatCommand, inputModel: IInputModel): void;
42+
onSubmit(inputModel: IInputModel): Promise<void>;
3243
}
3344

3445
export type ChatCommand = {
@@ -56,13 +67,24 @@ export type ChatCommand = {
5667
description?: string;
5768

5869
/**
59-
* If set, Jupyter Chat will replace the current word with this string after
60-
* the command is run from the chat commands menu.
70+
* If set, Jupyter Chat will replace the current word with this string immediately when
71+
* the command is accepted from the chat commands menu or the current word
72+
* matches the command's `name` exactly.
6173
*
62-
* If all commands from a provider have this property set, then
63-
* `handleChatCommands()` can just return on the first line.
74+
* This is generally used by "shortcut command" providers, e.g. the emoji
75+
* command provider.
6476
*/
6577
replaceWith?: string;
78+
79+
/**
80+
* Specifies whether the application should add a space ' ' after the command
81+
* is accepted from the menu. This should be set to `true` if the command that
82+
* replaces the current word needs to be handled on submit, and the command is
83+
* valid on its own.
84+
*
85+
* Defaults to `false`.
86+
*/
87+
spaceOnAccept?: boolean;
6688
};
6789

6890
/**
@@ -75,20 +97,23 @@ export interface IChatCommandProvider {
7597
id: string;
7698

7799
/**
78-
* Async function which accepts the input model and returns a list of
79-
* valid chat commands that match the current word. The current word is
80-
* space-separated word at the user's cursor.
100+
* A method that should return the list of valid chat commands whose names
101+
* complete the current word.
102+
*
103+
* The current word should be accessed from `inputModel.currentWord`.
81104
*/
82-
getChatCommands(inputModel: IInputModel): Promise<ChatCommand[]>;
105+
listCommandCompletions(inputModel: IInputModel): Promise<ChatCommand[]>;
83106

84107
/**
85-
* Function called when a chat command is run by the user through the chat
86-
* commands menu.
108+
* A method that should identify and handle *all* command calls within a
109+
* message that the user intends to submit. This method is called after a user
110+
* presses the "Send" button but before the message is sent to the server.
111+
*
112+
* The entire message should be read from `inputModel.value`. This method may
113+
* modify the new message before submission by setting `inputModel.value` or
114+
* by calling other methods available on `inputModel`.
87115
*/
88-
handleChatCommand(
89-
command: ChatCommand,
90-
inputModel: IInputModel
91-
): Promise<void>;
116+
onSubmit(inputModel: IInputModel): Promise<void>;
92117
}
93118

94119
/**
@@ -107,17 +132,10 @@ export class ChatCommandRegistry implements IChatCommandRegistry {
107132
return Array.from(this._providers.values());
108133
}
109134

110-
handleChatCommand(command: ChatCommand, inputModel: IInputModel) {
111-
const provider = this._providers.get(command.providerId);
112-
if (!provider) {
113-
console.error(
114-
'Error in handling chat command: No command provider has an ID of ' +
115-
command.providerId
116-
);
117-
return;
135+
async onSubmit(inputModel: IInputModel) {
136+
for (const provider of this._providers.values()) {
137+
await provider.onSubmit(inputModel);
118138
}
119-
120-
provider.handleChatCommand(command, inputModel);
121139
}
122140

123141
private _providers: Map<string, IChatCommandProvider>;

packages/jupyterlab-chat-extension/src/chat-commands/providers/emoji.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,28 @@ export class EmojiCommandProvider implements IChatCommandProvider {
1616
private _slash_commands: ChatCommand[] = [
1717
{
1818
name: ':heart:',
19-
replaceWith: '❤ ',
19+
replaceWith: '❤',
2020
providerId: this.id,
2121
description: 'Emoji',
2222
icon: '❤'
2323
},
2424
{
2525
name: ':smile:',
26-
replaceWith: '🙂 ',
26+
replaceWith: '🙂',
2727
providerId: this.id,
2828
description: 'Emoji',
2929
icon: '🙂'
3030
},
3131
{
3232
name: ':thinking:',
33-
replaceWith: '🤔 ',
33+
replaceWith: '🤔',
3434
providerId: this.id,
3535
description: 'Emoji',
3636
icon: '🤔'
3737
},
3838
{
3939
name: ':cool:',
40-
replaceWith: '😎 ',
40+
replaceWith: '😎',
4141
providerId: this.id,
4242
description: 'Emoji',
4343
icon: '😎'
@@ -47,7 +47,7 @@ export class EmojiCommandProvider implements IChatCommandProvider {
4747
// regex used to test the current word
4848
private _regex: RegExp = /^:\w*:?/;
4949

50-
async getChatCommands(inputModel: IInputModel) {
50+
async listCommandCompletions(inputModel: IInputModel) {
5151
const match = inputModel.currentWord?.match(this._regex)?.[0];
5252
if (!match) {
5353
return [];
@@ -59,11 +59,10 @@ export class EmojiCommandProvider implements IChatCommandProvider {
5959
return commands;
6060
}
6161

62-
async handleChatCommand(
63-
command: ChatCommand,
64-
inputModel: IInputModel
65-
): Promise<void> {
66-
// no handling needed because `replaceWith` is set in each command.
62+
async onSubmit(inputModel: IInputModel) {
63+
// this provider only provides commands that replace the current word
64+
// when typed / selected from the menu. this method does not need to handle
65+
// anything on submission.
6766
return;
6867
}
6968
}

0 commit comments

Comments
 (0)