Skip to content

Commit 79158ab

Browse files
authored
add 'Generative AI' submenu (#971)
1 parent 29e2a47 commit 79158ab

File tree

6 files changed

+205
-7
lines changed

6 files changed

+205
-7
lines changed

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ export function SendButton(props: SendButtonProps): JSX.Element {
8585
if (activeCell.exists) {
8686
props.onSend({
8787
type: 'cell',
88-
source: activeCell.manager.getContent(false).source
88+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
89+
source: activeCell.manager.getContent(false)!.source
8990
});
9091
closeMenu();
9192
return;

packages/jupyter-ai/src/contexts/active-cell-context.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export class ActiveCellManager {
8383
* `ActiveCellContentWithError` object that describes both the active cell and
8484
* the error output.
8585
*/
86-
getContent(withError: false): CellContent;
86+
getContent(withError: false): CellContent | null;
8787
getContent(withError: true): CellWithErrorContent | null;
8888
getContent(withError = false): CellContent | CellWithErrorContent | null {
8989
const sharedModel = this._activeCell?.model.sharedModel;

packages/jupyter-ai/src/index.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ import { ChatHandler } from './chat_handler';
1818
import { buildErrorWidget } from './widgets/chat-error';
1919
import { completionPlugin } from './completions';
2020
import { statusItemPlugin } from './status';
21-
import { IJaiCompletionProvider, IJaiMessageFooter } from './tokens';
21+
import { IJaiCompletionProvider, IJaiCore, IJaiMessageFooter } from './tokens';
2222
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
2323
import { ActiveCellManager } from './contexts/active-cell-context';
2424
import { Signal } from '@lumino/signaling';
25+
import { menuPlugin } from './plugins/menu-plugin';
2526

2627
export type DocumentTracker = IWidgetTracker<IDocumentWidget>;
2728

@@ -35,17 +36,18 @@ export namespace CommandIDs {
3536
/**
3637
* Initialization data for the jupyter_ai extension.
3738
*/
38-
const plugin: JupyterFrontEndPlugin<void> = {
39+
const plugin: JupyterFrontEndPlugin<IJaiCore> = {
3940
id: '@jupyter-ai/core:plugin',
4041
autoStart: true,
42+
requires: [IRenderMimeRegistry],
4143
optional: [
4244
IGlobalAwareness,
4345
ILayoutRestorer,
4446
IThemeManager,
4547
IJaiCompletionProvider,
4648
IJaiMessageFooter
4749
],
48-
requires: [IRenderMimeRegistry],
50+
provides: IJaiCore,
4951
activate: async (
5052
app: JupyterFrontEnd,
5153
rmRegistry: IRenderMimeRegistry,
@@ -114,7 +116,14 @@ const plugin: JupyterFrontEndPlugin<void> = {
114116
},
115117
label: 'Focus the jupyter-ai chat'
116118
});
119+
120+
return {
121+
activeCellManager,
122+
chatHandler,
123+
chatWidget,
124+
selectionWatcher
125+
};
117126
}
118127
};
119128

120-
export default [plugin, statusItemPlugin, completionPlugin];
129+
export default [plugin, statusItemPlugin, completionPlugin, menuPlugin];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import {
2+
JupyterFrontEnd,
3+
JupyterFrontEndPlugin
4+
} from '@jupyterlab/application';
5+
6+
import { IJaiCore } from '../tokens';
7+
import { AiService } from '../handler';
8+
import { Menu } from '@lumino/widgets';
9+
import { CommandRegistry } from '@lumino/commands';
10+
11+
export namespace CommandIDs {
12+
export const explain = 'jupyter-ai:explain';
13+
export const fix = 'jupyter-ai:fix';
14+
export const optimize = 'jupyter-ai:optimize';
15+
export const refactor = 'jupyter-ai:refactor';
16+
}
17+
18+
/**
19+
* Optional plugin that adds a "Generative AI" submenu to the context menu.
20+
* These implement UI shortcuts that explain, fix, refactor, or optimize code in
21+
* a notebook or file.
22+
*
23+
* **This plugin is experimental and may be removed in a future release.**
24+
*/
25+
export const menuPlugin: JupyterFrontEndPlugin<void> = {
26+
id: '@jupyter-ai/core:menu-plugin',
27+
autoStart: true,
28+
requires: [IJaiCore],
29+
activate: (app: JupyterFrontEnd, jaiCore: IJaiCore) => {
30+
const { activeCellManager, chatHandler, chatWidget, selectionWatcher } =
31+
jaiCore;
32+
33+
function activateChatSidebar() {
34+
app.shell.activateById(chatWidget.id);
35+
}
36+
37+
function getSelection(): AiService.Selection | null {
38+
const textSelection = selectionWatcher.selection;
39+
const activeCell = activeCellManager.getContent(false);
40+
const selection: AiService.Selection | null = textSelection
41+
? { type: 'text', source: textSelection.text }
42+
: activeCell
43+
? { type: 'cell', source: activeCell.source }
44+
: null;
45+
46+
return selection;
47+
}
48+
49+
function buildLabelFactory(baseLabel: string): () => string {
50+
return () => {
51+
const textSelection = selectionWatcher.selection;
52+
const activeCell = activeCellManager.getContent(false);
53+
54+
return textSelection
55+
? `${baseLabel} (${textSelection.numLines} lines selected)`
56+
: activeCell
57+
? `${baseLabel} (1 active cell)`
58+
: baseLabel;
59+
};
60+
}
61+
62+
// register commands
63+
const menuCommands = new CommandRegistry();
64+
menuCommands.addCommand(CommandIDs.explain, {
65+
execute: () => {
66+
const selection = getSelection();
67+
if (!selection) {
68+
return;
69+
}
70+
71+
activateChatSidebar();
72+
chatHandler.sendMessage({
73+
prompt: 'Explain the code below.',
74+
selection
75+
});
76+
},
77+
label: buildLabelFactory('Explain code'),
78+
isEnabled: () => !!getSelection()
79+
});
80+
menuCommands.addCommand(CommandIDs.fix, {
81+
execute: () => {
82+
const activeCellWithError = activeCellManager.getContent(true);
83+
if (!activeCellWithError) {
84+
return;
85+
}
86+
87+
chatHandler.sendMessage({
88+
prompt: '/fix',
89+
selection: {
90+
type: 'cell-with-error',
91+
error: activeCellWithError.error,
92+
source: activeCellWithError.source
93+
}
94+
});
95+
},
96+
label: () => {
97+
const activeCellWithError = activeCellManager.getContent(true);
98+
return activeCellWithError
99+
? 'Fix code cell (1 error cell)'
100+
: 'Fix code cell (no error cell)';
101+
},
102+
isEnabled: () => {
103+
const activeCellWithError = activeCellManager.getContent(true);
104+
return !!activeCellWithError;
105+
}
106+
});
107+
menuCommands.addCommand(CommandIDs.optimize, {
108+
execute: () => {
109+
const selection = getSelection();
110+
if (!selection) {
111+
return;
112+
}
113+
114+
activateChatSidebar();
115+
chatHandler.sendMessage({
116+
prompt: 'Optimize the code below.',
117+
selection
118+
});
119+
},
120+
label: buildLabelFactory('Optimize code'),
121+
isEnabled: () => !!getSelection()
122+
});
123+
menuCommands.addCommand(CommandIDs.refactor, {
124+
execute: () => {
125+
const selection = getSelection();
126+
if (!selection) {
127+
return;
128+
}
129+
130+
activateChatSidebar();
131+
chatHandler.sendMessage({
132+
prompt: 'Refactor the code below.',
133+
selection
134+
});
135+
},
136+
label: buildLabelFactory('Refactor code'),
137+
isEnabled: () => !!getSelection()
138+
});
139+
140+
// add commands as a context menu item containing a "Generative AI" submenu
141+
const submenu = new Menu({
142+
commands: menuCommands
143+
});
144+
submenu.id = 'jupyter-ai:submenu';
145+
submenu.title.label = 'Generative AI';
146+
submenu.addItem({ command: CommandIDs.explain });
147+
submenu.addItem({ command: CommandIDs.fix });
148+
submenu.addItem({ command: CommandIDs.optimize });
149+
submenu.addItem({ command: CommandIDs.refactor });
150+
151+
app.contextMenu.addItem({
152+
type: 'submenu',
153+
selector: '.jp-Editor',
154+
rank: 1,
155+
submenu
156+
});
157+
}
158+
};

packages/jupyter-ai/src/selection-watcher.ts

+9
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ function getTextSelection(widget: Widget | null): Selection | null {
7676
start,
7777
end,
7878
text,
79+
numLines: text.split('\n').length,
7980
widgetId: widget.id,
8081
...(cellId && {
8182
cellId
@@ -88,6 +89,10 @@ export type Selection = CodeEditor.ITextSelection & {
8889
* The text within the selection as a string.
8990
*/
9091
text: string;
92+
/**
93+
* Number of lines contained by the text selection.
94+
*/
95+
numLines: number;
9196
/**
9297
* The ID of the document widget in which the selection was made.
9398
*/
@@ -109,6 +114,10 @@ export class SelectionWatcher {
109114
setInterval(this._poll.bind(this), 200);
110115
}
111116

117+
get selection(): Selection | null {
118+
return this._selection;
119+
}
120+
112121
get selectionChanged(): Signal<this, Selection | null> {
113122
return this._selectionChanged;
114123
}

packages/jupyter-ai/src/tokens.ts

+22-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import React from 'react';
22
import { Token } from '@lumino/coreutils';
33
import { ISignal } from '@lumino/signaling';
4-
import type { IRankedMenu } from '@jupyterlab/ui-components';
4+
import type { IRankedMenu, ReactWidget } from '@jupyterlab/ui-components';
5+
56
import { AiService } from './handler';
7+
import { ChatHandler } from './chat_handler';
8+
import { ActiveCellManager } from './contexts/active-cell-context';
9+
import { SelectionWatcher } from './selection-watcher';
610

711
export interface IJaiStatusItem {
812
addItem(item: IRankedMenu.IItemOptions): void;
@@ -46,3 +50,20 @@ export const IJaiMessageFooter = new Token<IJaiMessageFooter>(
4650
'jupyter_ai:IJaiMessageFooter',
4751
'Optional component that is used to render a footer on each Jupyter AI chat message, when provided.'
4852
);
53+
54+
export interface IJaiCore {
55+
chatWidget: ReactWidget;
56+
chatHandler: ChatHandler;
57+
activeCellManager: ActiveCellManager;
58+
selectionWatcher: SelectionWatcher;
59+
}
60+
61+
/**
62+
* The Jupyter AI core provider token. Frontend plugins that want to extend the
63+
* Jupyter AI frontend by adding features which send messages or observe the
64+
* current text selection & active cell should require this plugin.
65+
*/
66+
export const IJaiCore = new Token<IJaiCore>(
67+
'jupyter_ai:core',
68+
'The core implementation of the frontend.'
69+
);

0 commit comments

Comments
 (0)