3
3
* Distributed under the terms of the Modified BSD License.
4
4
*/
5
5
6
+ import React from 'react' ;
6
7
import { JupyterFrontEndPlugin } from '@jupyterlab/application' ;
7
8
import type { Contents } from '@jupyterlab/services' ;
8
9
import type { DocumentRegistry } from '@jupyterlab/docregistry' ;
@@ -12,6 +13,7 @@ import {
12
13
IInputModel ,
13
14
ChatCommand
14
15
} from '@jupyter/chat' ;
16
+ import FindInPage from '@mui/icons-material/FindInPage' ;
15
17
16
18
const CONTEXT_COMMANDS_PROVIDER_ID =
17
19
'@jupyter-ai/core:context-commands-provider' ;
@@ -21,17 +23,18 @@ const CONTEXT_COMMANDS_PROVIDER_ID =
21
23
*/
22
24
export class ContextCommandsProvider implements IChatCommandProvider {
23
25
public id : string = CONTEXT_COMMANDS_PROVIDER_ID ;
24
- private _context_commands : ChatCommand [ ] = [
25
- // TODO: add an icon!
26
- // import FindInPage from '@mui/icons-material/FindInPage';
27
- // may need to change the API to allow JSX els as icons
28
- {
29
- name : '@file' ,
30
- providerId : this . id ,
31
- replaceWith : '@file:' ,
32
- description : 'Include a file with your prompt'
33
- }
34
- ] ;
26
+
27
+ /**
28
+ * Regex that matches all valid `@file` calls. The first capturing group
29
+ * captures the path specified by the user. Paths may contain any combination
30
+ * of:
31
+ *
32
+ * `[a-zA-Z0-9], '/', '-', '_', '.', '@', '\\ ' (escaped space)`
33
+ *
34
+ * IMPORTANT: `+` ensures this regex only matches an occurrence of "@file:" if
35
+ * the captured path is non-empty.
36
+ */
37
+ _regex : RegExp = / @ f i l e : ( ( [ \w / \- _ . @ ] | \\ ) + ) / g;
35
38
36
39
constructor (
37
40
contentsManager : Contents . IManager ,
@@ -41,15 +44,17 @@ export class ContextCommandsProvider implements IChatCommandProvider {
41
44
this . _docRegistry = docRegistry ;
42
45
}
43
46
44
- async getChatCommands ( inputModel : IInputModel ) {
47
+ async listCommandCompletions (
48
+ inputModel : IInputModel
49
+ ) : Promise < ChatCommand [ ] > {
45
50
// do nothing if the current word does not start with '@'.
46
51
const currentWord = inputModel . currentWord ;
47
52
if ( ! currentWord || ! currentWord . startsWith ( '@' ) ) {
48
53
return [ ] ;
49
54
}
50
55
51
56
// if the current word starts with `@file:`, return a list of valid file
52
- // paths.
57
+ // paths that complete the currently specified path .
53
58
if ( currentWord . startsWith ( '@file:' ) ) {
54
59
const searchPath = currentWord . split ( '@file:' ) [ 1 ] ;
55
60
const commands = await getPathCompletions (
@@ -60,19 +65,55 @@ export class ContextCommandsProvider implements IChatCommandProvider {
60
65
return commands ;
61
66
}
62
67
63
- // otherwise, a context command has not yet been specified. return a list of
64
- // valid context commands.
65
- const commands = this . _context_commands . filter ( cmd =>
66
- cmd . name . startsWith ( currentWord )
67
- ) ;
68
- return commands ;
68
+ // if the current word matches the start of @file , complete it
69
+ if ( '@file' . startsWith ( currentWord ) ) {
70
+ return [
71
+ {
72
+ name : '@file:' ,
73
+ providerId : this . id ,
74
+ description : 'Include a file with your prompt' ,
75
+ icon : < FindInPage />
76
+ }
77
+ ] ;
78
+ }
79
+
80
+ // otherwise, return nothing as this provider cannot provide any completions
81
+ // for the current word.
82
+ return [ ] ;
69
83
}
70
84
71
- async handleChatCommand (
72
- command : ChatCommand ,
73
- inputModel : IInputModel
74
- ) : Promise < void > {
75
- // no handling needed because `replaceWith` is set in each command.
85
+ async onSubmit ( inputModel : IInputModel ) : Promise < void > {
86
+ // search entire input for valid @file commands using `this._regex`
87
+ const matches = Array . from ( inputModel . value . matchAll ( this . _regex ) ) ;
88
+
89
+ // aggregate all file paths specified by @file commands in the input
90
+ const paths : string [ ] = [ ] ;
91
+ for ( const match of matches ) {
92
+ if ( match . length < 2 ) {
93
+ continue ;
94
+ }
95
+ // `this._regex` contains exactly 1 group that captures the path, so
96
+ // match[1] will contain the path specified by a @file command.
97
+ paths . push ( match [ 1 ] ) ;
98
+ }
99
+
100
+ // add each specified file path as an attachment, unescaping ' ' characters
101
+ // before doing so
102
+ for ( let path of paths ) {
103
+ path = path . replaceAll ( '\\ ' , ' ' ) ;
104
+ inputModel . addAttachment ?.( {
105
+ type : 'file' ,
106
+ value : path
107
+ } ) ;
108
+ }
109
+
110
+ // replace each @file command with the path in an inline Markdown code block
111
+ // for readability, both to humans & to the AI.
112
+ inputModel . value = inputModel . value . replaceAll (
113
+ this . _regex ,
114
+ ( command , path ) => `\`${ path } \``
115
+ ) ;
116
+
76
117
return ;
77
118
}
78
119
@@ -109,9 +150,15 @@ async function getPathCompletions(
109
150
contentsManager : Contents . IManager ,
110
151
docRegistry : DocumentRegistry ,
111
152
searchPath : string
112
- ) {
153
+ ) : Promise < ChatCommand [ ] > {
154
+ // get parent directory & the partial basename to be completed
113
155
const [ parentPath , basename ] = getParentAndBase ( searchPath ) ;
114
- const parentDir = await contentsManager . get ( parentPath ) ;
156
+
157
+ // query the parent directory through the CM, un-escaping spaces beforehand
158
+ const parentDir = await contentsManager . get (
159
+ parentPath . replaceAll ( '\\ ' , ' ' )
160
+ ) ;
161
+
115
162
const commands : ChatCommand [ ] = [ ] ;
116
163
117
164
if ( ! Array . isArray ( parentDir . content ) ) {
@@ -140,25 +187,29 @@ async function getPathCompletions(
140
187
// get icon
141
188
const { icon } = docRegistry . getFileTypeForModel ( child ) ;
142
189
143
- // compute list of results, while handling directories and non-directories
144
- // appropriately.
145
- const isDirectory = child . type === 'directory' ;
190
+ // calculate completion string, escaping any unescaped spaces
191
+ let completion = '@file:' + parentPath + child . name ;
192
+ completion = completion . replaceAll ( / (?< ! \\ ) / g, '\\ ' ) ;
193
+
194
+ // add command completion to the list
146
195
let newCommand : ChatCommand ;
196
+ const isDirectory = child . type === 'directory' ;
147
197
if ( isDirectory ) {
148
198
newCommand = {
149
199
name : child . name + '/' ,
150
200
providerId : CONTEXT_COMMANDS_PROVIDER_ID ,
151
201
icon,
152
202
description : 'Search this directory' ,
153
- replaceWith : '@file:' + parentPath + child . name + '/'
203
+ replaceWith : completion + '/'
154
204
} ;
155
205
} else {
156
206
newCommand = {
157
207
name : child . name ,
158
208
providerId : CONTEXT_COMMANDS_PROVIDER_ID ,
159
209
icon,
160
210
description : 'Attach this file' ,
161
- replaceWith : '@file:' + parentPath + child . name + ' '
211
+ replaceWith : completion ,
212
+ spaceOnAccept : true
162
213
} ;
163
214
}
164
215
commands . push ( newCommand ) ;
0 commit comments