From 7b9615e714bfc7ba305d3eef3a15455a6df99082 Mon Sep 17 00:00:00 2001 From: Jordi Adoumie Date: Sun, 31 Aug 2025 19:21:15 -0400 Subject: [PATCH 1/4] Added new text command for a more ergonomic way to attach text content to the chat --- .../src/chat-command-plugins/index.ts | 7 +- .../src/chat-command-plugins/text-command.tsx | 150 ++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 packages/jupyter-ai/src/chat-command-plugins/text-command.tsx diff --git a/packages/jupyter-ai/src/chat-command-plugins/index.ts b/packages/jupyter-ai/src/chat-command-plugins/index.ts index ba996f62b..dd8336920 100644 --- a/packages/jupyter-ai/src/chat-command-plugins/index.ts +++ b/packages/jupyter-ai/src/chat-command-plugins/index.ts @@ -1,4 +1,9 @@ import { fileCommandPlugin } from './file-command'; import { slashCommandPlugin } from './slash-commands'; +import { textCommandPlugin } from './text-command'; -export const chatCommandPlugins = [fileCommandPlugin, slashCommandPlugin]; +export const chatCommandPlugins = [ + fileCommandPlugin, + slashCommandPlugin, + textCommandPlugin +]; diff --git a/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx b/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx new file mode 100644 index 000000000..023b3e616 --- /dev/null +++ b/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx @@ -0,0 +1,150 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import React from 'react'; +import { JupyterFrontEndPlugin } from '@jupyterlab/application'; +import { + IChatCommandProvider, + IChatCommandRegistry, + IInputModel, + ChatCommand +} from '@jupyter/chat'; +import TextFieldsIcon from '@mui/icons-material/TextFields'; + +const TEXT_COMMAND_PROVIDER_ID = '@jupyter-ai/core:text-command-provider'; + +/** + * A command provider that provides completions for `@text` commands and handles + * `@text` command calls. + */ +export class TextCommandProvider implements IChatCommandProvider { + public id: string = TEXT_COMMAND_PROVIDER_ID; + + /** + * Regex that matches all potential `@text` commands. The first capturing + * group captures the text type specified by the user. + */ + _regex: RegExp = /@text:(active_cell|selected_text)/g; + + async listCommandCompletions( + inputModel: IInputModel + ): Promise { + // do nothing if the current word does not start with '@'. + const currentWord = inputModel.currentWord; + if (!currentWord || !currentWord.startsWith('@')) { + return []; + } + + // Check if any text options are available + const hasActiveCell = inputModel.activeCellManager?.available; + const hasSelectedText = !!inputModel.selectionWatcher?.selection; + + // Don't show @text at all if no options are available + if (!hasActiveCell && !hasSelectedText) { + return []; + } + + // if the current word starts with `@text:`, return filtered text type completions + if (currentWord.startsWith('@text:')) { + const searchText = currentWord.split('@text:')[1]; + const commands: ChatCommand[] = []; + + // Add active_cell option if available and matches search + if (hasActiveCell && 'active_cell'.startsWith(searchText)) { + commands.push({ + name: '@text:active_cell', + providerId: this.id, + icon: , + description: 'Include active cell content', + replaceWith: '@text:active_cell', + spaceOnAccept: true + }); + } + + // Add selected_text option if available and matches search + if (hasSelectedText && 'selected_text'.startsWith(searchText)) { + commands.push({ + name: '@text:selected_text', + providerId: this.id, + icon: , + description: 'Include selected text', + replaceWith: '@text:selected_text', + spaceOnAccept: true + }); + } + + return commands; + } + + // if the current word matches the start of @text, complete it + if ('@text'.startsWith(currentWord)) { + return [ + { + name: '@text:', + providerId: this.id, + description: 'Include text content', + icon: + } + ]; + } + + // otherwise, return nothing as this provider cannot provide any completions + // for the current word. + return []; + } + + async onSubmit(inputModel: IInputModel): Promise { + // replace each @text command with the actual content as markdown code blocks + inputModel.value = inputModel.value.replaceAll( + this._regex, + (match, textType) => { + let source = ''; + let language: string | undefined; + + if ( + textType === 'active_cell' && + inputModel.activeCellManager?.available + ) { + const cellContent = inputModel.activeCellManager.getContent(false); + if (cellContent) { + source = cellContent.source; + language = cellContent.language; + } + } else if ( + textType === 'selected_text' && + inputModel.selectionWatcher?.selection + ) { + const selection = inputModel.selectionWatcher.selection; + source = selection.text; + language = selection.language; + } + + if (source) { + return ` + +\`\`\`${language ?? ''} +${source} +\`\`\` +`; + } + + // fallback if no content found + return `\`${textType}\``; + } + ); + + return; + } +} + +export const textCommandPlugin: JupyterFrontEndPlugin = { + id: '@jupyter-ai/core:text-command-plugin', + description: 'Adds support for the @text command in Jupyter AI.', + autoStart: true, + requires: [IChatCommandRegistry], + activate: (app, registry: IChatCommandRegistry) => { + registry.addProvider(new TextCommandProvider()); + } +}; \ No newline at end of file From 59233ef2824d074bdc11078b04797b52580ef23d Mon Sep 17 00:00:00 2001 From: Jordi Adoumie Date: Sun, 31 Aug 2025 19:57:53 -0400 Subject: [PATCH 2/4] ran prettier for formatting --- packages/jupyter-ai/src/chat-command-plugins/text-command.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx b/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx index 023b3e616..587a6391d 100644 --- a/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx +++ b/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx @@ -147,4 +147,4 @@ export const textCommandPlugin: JupyterFrontEndPlugin = { activate: (app, registry: IChatCommandRegistry) => { registry.addProvider(new TextCommandProvider()); } -}; \ No newline at end of file +}; From 48d9cfbfcede20709790fa6ee24e252d24278ccb Mon Sep 17 00:00:00 2001 From: joadoumie Date: Mon, 29 Sep 2025 22:30:26 -0700 Subject: [PATCH 3/4] changed text commands to selection command that attaches notebook cells --- .../src/chat-command-plugins/index.ts | 4 +- .../src/chat-command-plugins/text-command.tsx | 230 +++++++++++------- 2 files changed, 146 insertions(+), 88 deletions(-) diff --git a/packages/jupyter-ai/src/chat-command-plugins/index.ts b/packages/jupyter-ai/src/chat-command-plugins/index.ts index dd8336920..ca4b5988c 100644 --- a/packages/jupyter-ai/src/chat-command-plugins/index.ts +++ b/packages/jupyter-ai/src/chat-command-plugins/index.ts @@ -1,9 +1,9 @@ import { fileCommandPlugin } from './file-command'; import { slashCommandPlugin } from './slash-commands'; -import { textCommandPlugin } from './text-command'; +import { selectionCommandPlugin } from './text-command'; export const chatCommandPlugins = [ fileCommandPlugin, slashCommandPlugin, - textCommandPlugin + selectionCommandPlugin ]; diff --git a/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx b/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx index 587a6391d..a9332f39b 100644 --- a/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx +++ b/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { JupyterFrontEndPlugin } from '@jupyterlab/application'; +import { INotebookTracker } from '@jupyterlab/notebook'; import { IChatCommandProvider, IChatCommandRegistry, @@ -13,20 +14,25 @@ import { } from '@jupyter/chat'; import TextFieldsIcon from '@mui/icons-material/TextFields'; -const TEXT_COMMAND_PROVIDER_ID = '@jupyter-ai/core:text-command-provider'; +const SELECTION_COMMAND_PROVIDER_ID = + '@jupyter-ai/core:selection-command-provider'; /** - * A command provider that provides completions for `@text` commands and handles - * `@text` command calls. + * A command provider that provides completions for `@selection` commands and handles + * `@selection` command calls. */ -export class TextCommandProvider implements IChatCommandProvider { - public id: string = TEXT_COMMAND_PROVIDER_ID; +export class SelectionCommandProvider implements IChatCommandProvider { + public id: string = SELECTION_COMMAND_PROVIDER_ID; + + constructor( + private shell: any, + private notebookTracker: INotebookTracker + ) {} /** - * Regex that matches all potential `@text` commands. The first capturing - * group captures the text type specified by the user. + * Regex that matches all potential `@selection` commands. */ - _regex: RegExp = /@text:(active_cell|selected_text)/g; + _regex: RegExp = /@selection/g; async listCommandCompletions( inputModel: IInputModel @@ -37,55 +43,29 @@ export class TextCommandProvider implements IChatCommandProvider { return []; } - // Check if any text options are available + // Check if any selection is available (prefer text selection, fallback to active cell) + const hasTextSelection = !!inputModel.selectionWatcher?.selection; const hasActiveCell = inputModel.activeCellManager?.available; - const hasSelectedText = !!inputModel.selectionWatcher?.selection; - // Don't show @text at all if no options are available - if (!hasActiveCell && !hasSelectedText) { + // Don't show @selection if no options are available + if (!hasTextSelection && !hasActiveCell) { return []; } - // if the current word starts with `@text:`, return filtered text type completions - if (currentWord.startsWith('@text:')) { - const searchText = currentWord.split('@text:')[1]; - const commands: ChatCommand[] = []; - - // Add active_cell option if available and matches search - if (hasActiveCell && 'active_cell'.startsWith(searchText)) { - commands.push({ - name: '@text:active_cell', - providerId: this.id, - icon: , - description: 'Include active cell content', - replaceWith: '@text:active_cell', - spaceOnAccept: true - }); - } - - // Add selected_text option if available and matches search - if (hasSelectedText && 'selected_text'.startsWith(searchText)) { - commands.push({ - name: '@text:selected_text', - providerId: this.id, - icon: , - description: 'Include selected text', - replaceWith: '@text:selected_text', - spaceOnAccept: true - }); - } - - return commands; - } + // if the current word matches the start of @selection, complete it + if ('@selection'.startsWith(currentWord)) { + const description = hasTextSelection + ? 'Include selected text' + : 'Include active cell content'; - // if the current word matches the start of @text, complete it - if ('@text'.startsWith(currentWord)) { return [ { - name: '@text:', + name: '@selection', providerId: this.id, - description: 'Include text content', - icon: + description, + icon: , + replaceWith: '@selection', + spaceOnAccept: true } ]; } @@ -96,55 +76,133 @@ export class TextCommandProvider implements IChatCommandProvider { } async onSubmit(inputModel: IInputModel): Promise { - // replace each @text command with the actual content as markdown code blocks - inputModel.value = inputModel.value.replaceAll( - this._regex, - (match, textType) => { - let source = ''; - let language: string | undefined; - - if ( - textType === 'active_cell' && - inputModel.activeCellManager?.available - ) { - const cellContent = inputModel.activeCellManager.getContent(false); - if (cellContent) { - source = cellContent.source; - language = cellContent.language; - } - } else if ( - textType === 'selected_text' && - inputModel.selectionWatcher?.selection - ) { - const selection = inputModel.selectionWatcher.selection; - source = selection.text; - language = selection.language; - } + // Check if the input contains @selection + if (!this._regex.test(inputModel.value)) { + return; + } - if (source) { - return ` + // Get the current widget from the shell + const currentWidget = this.shell.currentWidget; + if (!currentWidget) { + return; + } + + // Get the document context to access the path + const documentManager = inputModel.documentManager; + if (!documentManager) { + return; + } + + const context = documentManager.contextForWidget(currentWidget as any); + if (!context) { + return; + } -\`\`\`${language ?? ''} -${source} -\`\`\` -`; + const path = context.path; + + // Prefer text selection, fallback to active cell + const selection = inputModel.selectionWatcher?.selection; + const activeCellManager = inputModel.activeCellManager; + + if (selection && selection.text.trim().length > 0) { + // Text selection with actual content - create attachment with selection range + if (path.endsWith('.ipynb') && selection.cellId) { + // Notebook with cell selection - get the cell type from the active cell + const notebook = this.notebookTracker.currentWidget; + if (!notebook) { + return; } - // fallback if no content found - return `\`${textType}\``; + // Find the cell by ID to get its type + const cell = notebook.content.widgets.find( + c => c.model.id === selection.cellId + ); + const cellType = cell?.model.type ?? 'code'; + const inputType = + cellType === 'code' + ? 'code' + : cellType === 'markdown' + ? 'markdown' + : 'raw'; + + inputModel.addAttachment?.({ + type: 'notebook', + value: path, + cells: [ + { + id: selection.cellId, + input_type: inputType, + selection: { + start: [selection.start.line, selection.start.column], + end: [selection.end.line, selection.end.column], + content: selection.text + } + } + ] + }); + } else { + // Regular file with selection + inputModel.addAttachment?.({ + type: 'file', + value: path, + selection: { + start: [selection.start.line, selection.start.column], + end: [selection.end.line, selection.end.column], + content: selection.text + } + }); + } + } else if (activeCellManager?.available && path.endsWith('.ipynb')) { + // No meaningful text selection, but active cell is available in notebook + const notebook = this.notebookTracker.currentWidget; + if (!notebook) { + return; } - ); + + const activeCell = notebook.content.activeCell; + if (!activeCell) { + return; + } + + // Get cell ID and type + const cellId = activeCell.model.id; + const cellType = activeCell.model.type; + const inputType = + cellType === 'code' ? 'code' : cellType === 'markdown' ? 'markdown' : 'raw'; + + // Attach the specific cell without selection range + inputModel.addAttachment?.({ + type: 'notebook', + value: path, + cells: [ + { + id: cellId, + input_type: inputType + } + ] + }); + } + + // replace @selection command with a label for readability + inputModel.value = inputModel.value.replace(this._regex, `\`selection\``); return; } } -export const textCommandPlugin: JupyterFrontEndPlugin = { - id: '@jupyter-ai/core:text-command-plugin', - description: 'Adds support for the @text command in Jupyter AI.', +export const selectionCommandPlugin: JupyterFrontEndPlugin = { + id: '@jupyter-ai/core:selection-command-plugin', + description: 'Adds support for the @selection command in Jupyter AI.', autoStart: true, requires: [IChatCommandRegistry], - activate: (app, registry: IChatCommandRegistry) => { - registry.addProvider(new TextCommandProvider()); + optional: [INotebookTracker], + activate: ( + app, + registry: IChatCommandRegistry, + notebookTracker: INotebookTracker | null + ) => { + registry.addProvider( + new SelectionCommandProvider(app.shell, notebookTracker!) + ); } }; From 22d62ffbe779dcbee72979cb9184867bca054ba3 Mon Sep 17 00:00:00 2001 From: joadoumie Date: Mon, 29 Sep 2025 22:35:48 -0700 Subject: [PATCH 4/4] fix overriding user input --- packages/jupyter-ai/src/chat-command-plugins/text-command.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx b/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx index a9332f39b..1991d9791 100644 --- a/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx +++ b/packages/jupyter-ai/src/chat-command-plugins/text-command.tsx @@ -183,9 +183,6 @@ export class SelectionCommandProvider implements IChatCommandProvider { }); } - // replace @selection command with a label for readability - inputModel.value = inputModel.value.replace(this._regex, `\`selection\``); - return; } }