Skip to content

Commit 514a21b

Browse files
authored
Code highlight in message (#226)
* Highlight code attached from a cell * Highlight code attached from a selection * Add test * Apply suggestions from PR comments
1 parent 71359b7 commit 514a21b

File tree

8 files changed

+113
-19
lines changed

8 files changed

+113
-19
lines changed

packages/jupyter-chat/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
"@jupyter/react-components": "^0.15.2",
4949
"@jupyterlab/application": "^4.2.0",
5050
"@jupyterlab/apputils": "^4.3.0",
51+
"@jupyterlab/codeeditor": "^4.2.0",
52+
"@jupyterlab/codemirror": "^4.2.0",
5153
"@jupyterlab/docmanager": "^4.2.0",
5254
"@jupyterlab/filebrowser": "^4.2.0",
5355
"@jupyterlab/fileeditor": "^4.2.0",

packages/jupyter-chat/src/active-cell-manager.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import { ISignal, Signal } from '@lumino/signaling';
1313
type CellContent = {
1414
type: string;
1515
source: string;
16+
language?: string;
1617
};
1718

1819
type CellWithErrorContent = {
1920
type: 'code';
2021
source: string;
22+
language?: string;
2123
error: {
2224
name: string;
2325
value: string;
@@ -148,6 +150,11 @@ export class ActiveCellManager implements IActiveCellManager {
148150
getContent(withError: true): CellWithErrorContent | null;
149151
getContent(withError = false): CellContent | CellWithErrorContent | null {
150152
const sharedModel = this._notebookTracker.activeCell?.model.sharedModel;
153+
const language =
154+
sharedModel?.cell_type === 'code'
155+
? this._notebookTracker.currentWidget?.model?.defaultKernelLanguage
156+
: undefined;
157+
151158
if (!sharedModel) {
152159
return null;
153160
}
@@ -156,7 +163,8 @@ export class ActiveCellManager implements IActiveCellManager {
156163
if (!withError) {
157164
return {
158165
type: sharedModel.cell_type,
159-
source: sharedModel.getSource()
166+
source: sharedModel.getSource(),
167+
language
160168
};
161169
}
162170

@@ -166,6 +174,7 @@ export class ActiveCellManager implements IActiveCellManager {
166174
return {
167175
type: 'code',
168176
source: sharedModel.getSource(),
177+
language,
169178
error: {
170179
name: error.ename,
171180
value: error.evalue,

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,22 @@ export function SendButton(
109109
// Run all chat command providers
110110
await chatCommandRegistry?.onSubmit(model);
111111

112+
let language: string | undefined;
112113
if (selectionWatcher?.selection) {
113114
// Append the selected text if exists.
114115
source = selectionWatcher.selection.text;
116+
language = selectionWatcher.selection.language;
115117
} else if (activeCellManager?.available) {
116118
// Append the active cell content if exists.
117-
source = activeCellManager.getContent(false)!.source;
119+
const content = activeCellManager.getContent(false);
120+
source = content!.source;
121+
language = content?.language;
118122
}
119123
let content = model.value;
120124
if (source) {
121125
content += `
122126
123-
\`\`\`
127+
\`\`\`${language ?? ''}
124128
${source}
125129
\`\`\`
126130
`;

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

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
import { JupyterFrontEnd } from '@jupyterlab/application';
77
import { DocumentWidget } from '@jupyterlab/docregistry';
88
import { CodeEditor } from '@jupyterlab/codeeditor';
9+
import {
10+
EditorLanguageRegistry,
11+
IEditorLanguageRegistry
12+
} from '@jupyterlab/codemirror';
913
import { Notebook } from '@jupyterlab/notebook';
1014

1115
import { find } from '@lumino/algorithm';
@@ -26,6 +30,10 @@ export namespace SelectionWatcher {
2630
* The current shell of the application.
2731
*/
2832
shell: JupyterFrontEnd.IShell;
33+
/**
34+
* Editor language registry.
35+
*/
36+
languages?: IEditorLanguageRegistry;
2937
}
3038

3139
/**
@@ -44,6 +52,10 @@ export namespace SelectionWatcher {
4452
* The ID of the document widget in which the selection was made.
4553
*/
4654
widgetId: string;
55+
/**
56+
* The language of the selection.
57+
*/
58+
language?: string;
4759
/**
4860
* The ID of the cell in which the selection was made, if the original widget
4961
* was a notebook.
@@ -70,6 +82,7 @@ export interface ISelectionWatcher {
7082
export class SelectionWatcher {
7183
constructor(options: SelectionWatcher.IOptions) {
7284
this._shell = options.shell;
85+
this._languages = options.languages || new EditorLanguageRegistry();
7386
this._shell.currentChanged?.connect((sender, args) => {
7487
// Do not change the main area widget if the new one has no editor, for example
7588
// a chat panel. However, the selected text is only available if the main area
@@ -149,12 +162,15 @@ export class SelectionWatcher {
149162
editor.setSelection({ start: newPosition, end: newPosition });
150163
}
151164

152-
protected _poll(): void {
165+
protected async _poll(): Promise<void> {
153166
let currSelection: SelectionWatcher.Selection | null = null;
154167
const prevSelection = this._selection;
155168
// Do not return selected text if the main area widget is hidden.
156169
if (this._mainAreaDocumentWidget?.isVisible) {
157-
currSelection = getTextSelection(this._mainAreaDocumentWidget);
170+
currSelection = await getTextSelection(
171+
this._mainAreaDocumentWidget,
172+
this._languages
173+
);
158174
}
159175
if (prevSelection?.text !== currSelection?.text) {
160176
this._selection = currSelection;
@@ -169,14 +185,16 @@ export class SelectionWatcher {
169185
this,
170186
SelectionWatcher.Selection | null
171187
>(this);
188+
private _languages: IEditorLanguageRegistry;
172189
}
173190

174191
/**
175192
* Gets a Selection object from a document widget. Returns `null` if unable.
176193
*/
177-
function getTextSelection(
178-
widget: Widget | null
179-
): SelectionWatcher.Selection | null {
194+
async function getTextSelection(
195+
widget: Widget | null,
196+
languages: IEditorLanguageRegistry
197+
): Promise<SelectionWatcher.Selection | null> {
180198
const editor = getEditor(widget);
181199
// widget type check is redundant but hints the type to TypeScript
182200
if (!editor || !(widget instanceof DocumentWidget)) {
@@ -207,13 +225,18 @@ function getTextSelection(
207225
[start, end] = [end, start];
208226
}
209227

228+
const language = (await languages.getLanguage(editor?.model.mimeType))?.name;
210229
return {
211230
...selectionObj,
212231
start,
213232
end,
214233
text,
215234
numLines: text.split('\n').length,
216235
widgetId: widget.id,
236+
237+
...(language && {
238+
language
239+
}),
217240
...(cellId && {
218241
cellId
219242
})

packages/jupyterlab-chat-extension/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"@jupyter/ydoc": "^3.0.0",
5353
"@jupyterlab/application": "^4.4.0",
5454
"@jupyterlab/apputils": "^4.5.0",
55+
"@jupyterlab/codemirror": "^4.4.0",
5556
"@jupyterlab/coreutils": "^6.4.0",
5657
"@jupyterlab/docregistry": "^4.4.0",
5758
"@jupyterlab/filebrowser": "^4.4.0",

packages/jupyterlab-chat-extension/src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
createToolbarFactory,
3636
showErrorMessage
3737
} from '@jupyterlab/apputils';
38+
import { IEditorLanguageRegistry } from '@jupyterlab/codemirror';
3839
import { PathExt } from '@jupyterlab/coreutils';
3940
import { DocumentRegistry } from '@jupyterlab/docregistry';
4041
import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
@@ -813,9 +814,14 @@ const selectionWatcher: JupyterFrontEndPlugin<ISelectionWatcher> = {
813814
description: 'The selection watcher plugin.',
814815
autoStart: true,
815816
provides: ISelectionWatcherToken,
816-
activate: (app: JupyterFrontEnd): ISelectionWatcher => {
817+
optional: [IEditorLanguageRegistry],
818+
activate: (
819+
app: JupyterFrontEnd,
820+
languages: IEditorLanguageRegistry
821+
): ISelectionWatcher => {
817822
return new SelectionWatcher({
818-
shell: app.shell
823+
shell: app.shell,
824+
languages
819825
});
820826
}
821827
};

ui-tests/tests/send-message.spec.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ test.describe('#sendMessages', () => {
164164
);
165165
});
166166

167-
test('should send with cell content', async ({ page }) => {
167+
test('should send with code cell content', async ({ page }) => {
168168
const cellContent = 'a = 1\nprint(f"a={a}")';
169169
const chatPanel = await openChat(page, FILENAME);
170170
const messages = chatPanel.locator('.jp-chat-messages-container');
@@ -189,14 +189,56 @@ test.describe('#sendMessages', () => {
189189
await expect(sendWithSelection).toBeEnabled();
190190
await expect(sendWithSelection).toContainText('Code from 1 active cell');
191191
await sendWithSelection.click();
192+
await expect(messages!.locator('.jp-chat-message')).toHaveCount(1);
193+
194+
// It seems that the markdown renderer adds a new line, but the '\n' inserter when
195+
// pressing Enter above is trimmed.
196+
const rendered = messages.locator(
197+
'.jp-chat-message .jp-chat-rendered-markdown'
198+
);
199+
await expect(rendered).toHaveText(`${MSG_CONTENT}\n${cellContent}\n`);
192200

201+
// Code should have python language class.
202+
await expect(rendered.locator('code')).toHaveClass('language-python');
203+
});
204+
205+
test('should send with markdown cell content', async ({ page }) => {
206+
const cellContent = 'markdown content';
207+
const chatPanel = await openChat(page, FILENAME);
208+
const messages = chatPanel.locator('.jp-chat-messages-container');
209+
const input = chatPanel
210+
.locator('.jp-chat-input-container')
211+
.getByRole('combobox');
212+
const openerButton = chatPanel.locator(
213+
'.jp-chat-input-container .jp-chat-send-include-opener'
214+
);
215+
const sendWithSelection = page.locator('.jp-chat-send-include');
216+
217+
const notebook = await page.notebook.createNew();
218+
// write content in the first cell after changing it to markdown.
219+
const cell = (await page.notebook.getCellLocator(0))!;
220+
await page.notebook.setCellType(0, 'markdown');
221+
await cell.getByRole('textbox').pressSequentially(cellContent);
222+
223+
await splitMainArea(page, notebook!);
224+
225+
await input.pressSequentially(MSG_CONTENT);
226+
await openerButton.click();
227+
await expect(sendWithSelection).toBeVisible();
228+
await expect(sendWithSelection).toBeEnabled();
229+
await expect(sendWithSelection).toContainText('Code from 1 active cell');
230+
await sendWithSelection.click();
193231
await expect(messages!.locator('.jp-chat-message')).toHaveCount(1);
194232

195233
// It seems that the markdown renderer adds a new line, but the '\n' inserter when
196234
// pressing Enter above is trimmed.
197-
await expect(
198-
messages.locator('.jp-chat-message .jp-chat-rendered-markdown')
199-
).toHaveText(`${MSG_CONTENT}\n${cellContent}\n`);
235+
const rendered = messages.locator(
236+
'.jp-chat-message .jp-chat-rendered-markdown'
237+
);
238+
await expect(rendered).toHaveText(`${MSG_CONTENT}\n${cellContent}\n`);
239+
240+
// Code should not have python language class since it come from a markdown cell.
241+
await expect(rendered.locator('code')).toHaveClass('');
200242
});
201243

202244
test('should send with text selection', async ({ page }) => {
@@ -243,8 +285,12 @@ test.describe('#sendMessages', () => {
243285

244286
// It seems that the markdown renderer adds a new line, but the '\n' inserter when
245287
// pressing Enter above is trimmed.
246-
await expect(
247-
messages.locator('.jp-chat-message .jp-chat-rendered-markdown')
248-
).toHaveText(`${MSG_CONTENT}\nprint\n`);
288+
const rendered = messages.locator(
289+
'.jp-chat-message .jp-chat-rendered-markdown'
290+
);
291+
await expect(rendered).toHaveText(`${MSG_CONTENT}\nprint\n`);
292+
293+
// Code should have python or ipython language class.
294+
await expect(rendered.locator('code')).toHaveClass(/language-[i]?python/);
249295
});
250296
});

yarn.lock

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2269,6 +2269,8 @@ __metadata:
22692269
"@jupyter/react-components": ^0.15.2
22702270
"@jupyterlab/application": ^4.2.0
22712271
"@jupyterlab/apputils": ^4.3.0
2272+
"@jupyterlab/codeeditor": ^4.2.0
2273+
"@jupyterlab/codemirror": ^4.2.0
22722274
"@jupyterlab/docmanager": ^4.2.0
22732275
"@jupyterlab/filebrowser": ^4.2.0
22742276
"@jupyterlab/fileeditor": ^4.2.0
@@ -2534,7 +2536,7 @@ __metadata:
25342536
languageName: node
25352537
linkType: hard
25362538

2537-
"@jupyterlab/codeeditor@npm:^4.2.3, @jupyterlab/codeeditor@npm:^4.4.3":
2539+
"@jupyterlab/codeeditor@npm:^4.2.0, @jupyterlab/codeeditor@npm:^4.2.3, @jupyterlab/codeeditor@npm:^4.4.3":
25382540
version: 4.4.3
25392541
resolution: "@jupyterlab/codeeditor@npm:4.4.3"
25402542
dependencies:
@@ -2558,7 +2560,7 @@ __metadata:
25582560
languageName: node
25592561
linkType: hard
25602562

2561-
"@jupyterlab/codemirror@npm:^4.2.3, @jupyterlab/codemirror@npm:^4.4.3":
2563+
"@jupyterlab/codemirror@npm:^4.2.0, @jupyterlab/codemirror@npm:^4.2.3, @jupyterlab/codemirror@npm:^4.4.0, @jupyterlab/codemirror@npm:^4.4.3":
25622564
version: 4.4.3
25632565
resolution: "@jupyterlab/codemirror@npm:4.4.3"
25642566
dependencies:
@@ -9724,6 +9726,7 @@ __metadata:
97249726
"@jupyterlab/application": ^4.4.0
97259727
"@jupyterlab/apputils": ^4.5.0
97269728
"@jupyterlab/builder": ^4.2.0
9729+
"@jupyterlab/codemirror": ^4.4.0
97279730
"@jupyterlab/coreutils": ^6.4.0
97289731
"@jupyterlab/docregistry": ^4.4.0
97299732
"@jupyterlab/filebrowser": ^4.4.0

0 commit comments

Comments
 (0)