From 4013b090d3ef5520b5fbaa629158f6109a812443 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 10:29:21 -0500 Subject: [PATCH 01/25] Initial predefined assistant actions --- .../browser/positronNotebook.contribution.ts | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts index 2117f790f36b..c0f7f92c9169 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; @@ -46,6 +46,8 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { KernelStatusBadge } from './KernelStatusBadge.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { CHAT_OPEN_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { UpdateNotebookWorkingDirectoryAction } from './UpdateNotebookWorkingDirectoryAction.js'; import { IPositronNotebookInstance } from './IPositronNotebookInstance.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; @@ -1322,6 +1324,87 @@ registerAction2(class extends NotebookAction2 { } }); +// Ask Assistant - Opens assistant chat with prompt options for the notebook +registerAction2(class extends NotebookAction2 { + constructor() { + super({ + id: 'positronNotebook.askAssistant', + title: localize2('askAssistant', 'Ask Assistant'), + tooltip: localize2('askAssistant.tooltip', 'Ask the assistant about this notebook'), + icon: ThemeIcon.fromId('positron-assistant'), + f1: true, + category: POSITRON_NOTEBOOK_CATEGORY, + positronActionBarOptions: { + controlType: 'button', + displayTitle: false + }, + menu: { + id: MenuId.EditorActionsLeft, + group: 'navigation', + order: 50, + when: ContextKeyExpr.equals('activeEditor', POSITRON_NOTEBOOK_EDITOR_ID) + } + }); + } + + override async runNotebookAction(_notebook: IPositronNotebookInstance, accessor: ServicesAccessor) { + const commandService = accessor.get(ICommandService); + const quickInputService = accessor.get(IQuickInputService); + + // Create quick pick items with the three prompt options + interface PromptQuickPickItem extends IQuickPickItem { + query: string; + } + + const quickPickItems: PromptQuickPickItem[] = [ + { + label: localize('positronNotebook.askAssistant.prompt.describe', 'Can you describe the open notebook for me'), + query: 'Can you describe the open notebook for me?' + }, + { + label: localize('positronNotebook.askAssistant.prompt.comments', 'Can you add inline comments to the selected cell(s)'), + query: 'Can you add inline comments to the selected cell(s)?' + }, + { + label: localize('positronNotebook.askAssistant.prompt.suggest', 'Can you suggest next steps for this notebook'), + query: 'Can you suggest next steps for this notebook?' + } + ]; + + // Create and show the quick pick + const quickPick = quickInputService.createQuickPick(); + quickPick.title = localize('positronNotebook.askAssistant.quickPick.title', 'Select a prompt'); + quickPick.placeholder = localize('positronNotebook.askAssistant.quickPick.placeholder', 'Choose a prompt to send to the assistant'); + quickPick.items = quickPickItems; + quickPick.canSelectMany = false; + + quickPick.show(); + + // Wait for user selection + const selectedItem = await new Promise((resolve) => { + const disposables = new DisposableStore(); + disposables.add(quickPick.onDidAccept(() => { + resolve(quickPick.selectedItems[0]); + quickPick.dispose(); + disposables.dispose(); + })); + disposables.add(quickPick.onDidHide(() => { + resolve(undefined); + quickPick.dispose(); + disposables.dispose(); + })); + }); + + // If user selected an item, execute the chat command with the selected query + if (selectedItem) { + commandService.executeCommand(CHAT_OPEN_ACTION_ID, { + query: selectedItem.query, + mode: 'ask' + }); + } + } +}); + // Kernel Status Widget - Shows live kernel connection status at far right of action bar // Widget is self-contained: manages its own menu interactions via ActionBarMenuButton registerNotebookWidget({ From 41051283699aa3f275404fcbb40309f988dde008 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 10:42:16 -0500 Subject: [PATCH 02/25] Add option for user to write a custom prompt. --- .../browser/positronNotebook.contribution.ts | 73 ++++++++++++++----- 1 file changed, 56 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts index c0f7f92c9169..55a5e93a3dba 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts @@ -1354,37 +1354,76 @@ registerAction2(class extends NotebookAction2 { // Create quick pick items with the three prompt options interface PromptQuickPickItem extends IQuickPickItem { query: string; + mode: 'ask' | 'edit' | 'agent'; } - const quickPickItems: PromptQuickPickItem[] = [ + // Create items array with prompt options and helpful details + const assistantPredefinedActions: PromptQuickPickItem[] = [ { - label: localize('positronNotebook.askAssistant.prompt.describe', 'Can you describe the open notebook for me'), - query: 'Can you describe the open notebook for me?' + label: localize('positronNotebook.assistant.prompt.describe', 'Describe the notebook'), + detail: localize('positronNotebook.assistant.prompt.describe.detail', 'Get an overview of the notebook\'s contents and structure'), + query: 'Can you describe the open notebook for me?', + mode: 'ask' }, { - label: localize('positronNotebook.askAssistant.prompt.comments', 'Can you add inline comments to the selected cell(s)'), - query: 'Can you add inline comments to the selected cell(s)?' + label: localize('positronNotebook.assistant.prompt.comments', 'Add inline comments'), + detail: localize('positronNotebook.assistant.prompt.comments.detail', 'Add explanatory comments to the selected cell(s)'), + query: 'Can you add inline comments to the selected cell(s)?', + mode: 'edit' }, { - label: localize('positronNotebook.askAssistant.prompt.suggest', 'Can you suggest next steps for this notebook'), - query: 'Can you suggest next steps for this notebook?' + label: localize('positronNotebook.assistant.prompt.suggest', 'Suggest next steps'), + detail: localize('positronNotebook.assistant.prompt.suggest.detail', 'Get recommendations for what to do next with this notebook'), + query: 'Can you suggest next steps for this notebook?', + mode: 'ask' } ]; + // Create the description for the quick pick + const description = localize( + 'positronNotebook.assistant.quickPick.description', + 'Type your own prompt or select one of the options below.' + ); + // Create and show the quick pick const quickPick = quickInputService.createQuickPick(); - quickPick.title = localize('positronNotebook.askAssistant.quickPick.title', 'Select a prompt'); - quickPick.placeholder = localize('positronNotebook.askAssistant.quickPick.placeholder', 'Choose a prompt to send to the assistant'); - quickPick.items = quickPickItems; + quickPick.title = localize('positronNotebook.assistant.quickPick.title', 'Assistant'); + quickPick.description = description; + quickPick.placeholder = localize('positronNotebook.assistant.quickPick.placeholder', 'Type your prompt...'); + quickPick.items = assistantPredefinedActions; quickPick.canSelectMany = false; quickPick.show(); - // Wait for user selection - const selectedItem = await new Promise((resolve) => { + // Wait for user selection or custom input + const result = await new Promise<{ item: PromptQuickPickItem; isCustom: boolean } | undefined>((resolve) => { const disposables = new DisposableStore(); disposables.add(quickPick.onDidAccept(() => { - resolve(quickPick.selectedItems[0]); + // Check if a predefined item was selected + const selected = quickPick.selectedItems[0]; + const customValue = quickPick.value.trim(); + + if (selected && 'query' in selected && 'mode' in selected) { + // User selected a predefined prompt item + const promptItem: PromptQuickPickItem = { + label: selected.label, + query: selected.query, + mode: selected.mode + }; + resolve({ item: promptItem, isCustom: false }); + } else if (customValue) { + // User typed a custom prompt - create a temporary item with their input + // Default to 'agent' mode for custom prompts + const customItem: PromptQuickPickItem = { + label: customValue, + query: customValue, + mode: 'agent' + }; + resolve({ item: customItem, isCustom: true }); + } else { + // No selection and no input + resolve(undefined); + } quickPick.dispose(); disposables.dispose(); })); @@ -1395,11 +1434,11 @@ registerAction2(class extends NotebookAction2 { })); }); - // If user selected an item, execute the chat command with the selected query - if (selectedItem) { + // If user selected an item or typed a custom prompt, execute the chat command + if (result) { commandService.executeCommand(CHAT_OPEN_ACTION_ID, { - query: selectedItem.query, - mode: 'ask' + query: result.item.query, + mode: result.item.mode }); } } From 1c3dd79d274a872e071bc76012dced0770c0e836 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 10:45:47 -0500 Subject: [PATCH 03/25] Add codicons to improve scannability --- .../browser/positronNotebook.contribution.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts index 55a5e93a3dba..74f203764f50 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts @@ -43,6 +43,7 @@ import './contrib/undoRedo/positronNotebookUndoRedo.js'; import { registerAction2, MenuId, Action2, IAction2Options, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { ExecuteSelectionInConsoleAction } from './ExecuteSelectionInConsoleAction.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { KernelStatusBadge } from './KernelStatusBadge.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -1363,19 +1364,22 @@ registerAction2(class extends NotebookAction2 { label: localize('positronNotebook.assistant.prompt.describe', 'Describe the notebook'), detail: localize('positronNotebook.assistant.prompt.describe.detail', 'Get an overview of the notebook\'s contents and structure'), query: 'Can you describe the open notebook for me?', - mode: 'ask' + mode: 'ask', + iconClass: ThemeIcon.asClassName(Codicon.book) }, { label: localize('positronNotebook.assistant.prompt.comments', 'Add inline comments'), detail: localize('positronNotebook.assistant.prompt.comments.detail', 'Add explanatory comments to the selected cell(s)'), query: 'Can you add inline comments to the selected cell(s)?', - mode: 'edit' + mode: 'edit', + iconClass: ThemeIcon.asClassName(Codicon.commentAdd) }, { label: localize('positronNotebook.assistant.prompt.suggest', 'Suggest next steps'), detail: localize('positronNotebook.assistant.prompt.suggest.detail', 'Get recommendations for what to do next with this notebook'), query: 'Can you suggest next steps for this notebook?', - mode: 'ask' + mode: 'ask', + iconClass: ThemeIcon.asClassName(Codicon.lightbulb) } ]; From 4a10827414c2e51c86e2810190dad0722c28b6e3 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 10:58:31 -0500 Subject: [PATCH 04/25] cleanup --- .../browser/positronNotebook.contribution.ts | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts index 74f203764f50..44871fd32781 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts @@ -48,6 +48,7 @@ import { KernelStatusBadge } from './KernelStatusBadge.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { CHAT_OPEN_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; +import { ChatModeKind } from '../../chat/common/constants.js'; import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { UpdateNotebookWorkingDirectoryAction } from './UpdateNotebookWorkingDirectoryAction.js'; import { IPositronNotebookInstance } from './IPositronNotebookInstance.js'; @@ -1355,7 +1356,7 @@ registerAction2(class extends NotebookAction2 { // Create quick pick items with the three prompt options interface PromptQuickPickItem extends IQuickPickItem { query: string; - mode: 'ask' | 'edit' | 'agent'; + mode: ChatModeKind; } // Create items array with prompt options and helpful details @@ -1364,21 +1365,21 @@ registerAction2(class extends NotebookAction2 { label: localize('positronNotebook.assistant.prompt.describe', 'Describe the notebook'), detail: localize('positronNotebook.assistant.prompt.describe.detail', 'Get an overview of the notebook\'s contents and structure'), query: 'Can you describe the open notebook for me?', - mode: 'ask', + mode: ChatModeKind.Ask, iconClass: ThemeIcon.asClassName(Codicon.book) }, { label: localize('positronNotebook.assistant.prompt.comments', 'Add inline comments'), detail: localize('positronNotebook.assistant.prompt.comments.detail', 'Add explanatory comments to the selected cell(s)'), query: 'Can you add inline comments to the selected cell(s)?', - mode: 'edit', + mode: ChatModeKind.Edit, iconClass: ThemeIcon.asClassName(Codicon.commentAdd) }, { label: localize('positronNotebook.assistant.prompt.suggest', 'Suggest next steps'), detail: localize('positronNotebook.assistant.prompt.suggest.detail', 'Get recommendations for what to do next with this notebook'), query: 'Can you suggest next steps for this notebook?', - mode: 'ask', + mode: ChatModeKind.Ask, iconClass: ThemeIcon.asClassName(Codicon.lightbulb) } ]; @@ -1400,30 +1401,25 @@ registerAction2(class extends NotebookAction2 { quickPick.show(); // Wait for user selection or custom input - const result = await new Promise<{ item: PromptQuickPickItem; isCustom: boolean } | undefined>((resolve) => { + const result = await new Promise((resolve) => { const disposables = new DisposableStore(); disposables.add(quickPick.onDidAccept(() => { // Check if a predefined item was selected const selected = quickPick.selectedItems[0]; const customValue = quickPick.value.trim(); - if (selected && 'query' in selected && 'mode' in selected) { + if (selected) { // User selected a predefined prompt item - const promptItem: PromptQuickPickItem = { - label: selected.label, - query: selected.query, - mode: selected.mode - }; - resolve({ item: promptItem, isCustom: false }); + resolve(selected); } else if (customValue) { // User typed a custom prompt - create a temporary item with their input // Default to 'agent' mode for custom prompts const customItem: PromptQuickPickItem = { label: customValue, query: customValue, - mode: 'agent' + mode: ChatModeKind.Agent }; - resolve({ item: customItem, isCustom: true }); + resolve(customItem); } else { // No selection and no input resolve(undefined); @@ -1441,8 +1437,8 @@ registerAction2(class extends NotebookAction2 { // If user selected an item or typed a custom prompt, execute the chat command if (result) { commandService.executeCommand(CHAT_OPEN_ACTION_ID, { - query: result.item.query, - mode: result.item.mode + query: result.query, + mode: result.mode }); } } From 64f6b1e2b5bce672ba0ed46d67cc448a6ecec1f3 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 11:03:05 -0500 Subject: [PATCH 05/25] Add error handling and also cleanup quick pick disposal pattern --- .../browser/positronNotebook.contribution.ts | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts index 44871fd32781..41ed6fc4b8ea 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts @@ -3,7 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; @@ -50,6 +50,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { CHAT_OPEN_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; import { ChatModeKind } from '../../chat/common/constants.js'; import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { UpdateNotebookWorkingDirectoryAction } from './UpdateNotebookWorkingDirectoryAction.js'; import { IPositronNotebookInstance } from './IPositronNotebookInstance.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; @@ -1352,6 +1353,7 @@ registerAction2(class extends NotebookAction2 { override async runNotebookAction(_notebook: IPositronNotebookInstance, accessor: ServicesAccessor) { const commandService = accessor.get(ICommandService); const quickInputService = accessor.get(IQuickInputService); + const notificationService = accessor.get(INotificationService); // Create quick pick items with the three prompt options interface PromptQuickPickItem extends IQuickPickItem { @@ -1390,7 +1392,7 @@ registerAction2(class extends NotebookAction2 { 'Type your own prompt or select one of the options below.' ); - // Create and show the quick pick + // Create and configure the quick pick const quickPick = quickInputService.createQuickPick(); quickPick.title = localize('positronNotebook.assistant.quickPick.title', 'Assistant'); quickPick.description = description; @@ -1398,12 +1400,9 @@ registerAction2(class extends NotebookAction2 { quickPick.items = assistantPredefinedActions; quickPick.canSelectMany = false; - quickPick.show(); - // Wait for user selection or custom input const result = await new Promise((resolve) => { - const disposables = new DisposableStore(); - disposables.add(quickPick.onDidAccept(() => { + quickPick.onDidAccept(() => { // Check if a predefined item was selected const selected = quickPick.selectedItems[0]; const customValue = quickPick.value.trim(); @@ -1425,21 +1424,32 @@ registerAction2(class extends NotebookAction2 { resolve(undefined); } quickPick.dispose(); - disposables.dispose(); - })); - disposables.add(quickPick.onDidHide(() => { - resolve(undefined); + }); + + quickPick.show(); + + quickPick.onDidHide(() => { quickPick.dispose(); - disposables.dispose(); - })); + resolve(undefined); + }); }); // If user selected an item or typed a custom prompt, execute the chat command if (result) { - commandService.executeCommand(CHAT_OPEN_ACTION_ID, { - query: result.query, - mode: result.mode - }); + try { + await commandService.executeCommand(CHAT_OPEN_ACTION_ID, { + query: result.query, + mode: result.mode + }); + } catch (error) { + notificationService.error( + localize( + 'positronNotebook.assistant.error', + 'Failed to open assistant chat: {0}', + error instanceof Error ? error.message : String(error) + ) + ); + } } } }); From 876e6516087601a82eab425d06419240df6bf739 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 11:10:19 -0500 Subject: [PATCH 06/25] Move ask assistant action to its own file --- .../browser/AskAssistantAction.ts | 162 ++++++++++++++++++ .../browser/positronNotebook.contribution.ts | 132 +------------- 2 files changed, 164 insertions(+), 130 deletions(-) create mode 100644 src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts diff --git a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts new file mode 100644 index 000000000000..62f9bc0f88db --- /dev/null +++ b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024-2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { CHAT_OPEN_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; +import { ChatModeKind } from '../../chat/common/constants.js'; +import { POSITRON_NOTEBOOK_EDITOR_ID } from '../common/positronNotebookCommon.js'; +import { getNotebookInstanceFromActiveEditorPane } from './notebookUtils.js'; + +const ASK_ASSISTANT_ACTION_ID = 'positronNotebook.askAssistant'; + +/** + * Interface for quick pick items that represent assistant prompt options + */ +interface PromptQuickPickItem extends IQuickPickItem { + query: string; + mode: ChatModeKind; +} + +/** + * Predefined prompt options for the assistant quick pick + */ +const ASSISTANT_PREDEFINED_ACTIONS: PromptQuickPickItem[] = [ + { + label: localize('positronNotebook.assistant.prompt.describe', 'Describe the notebook'), + detail: localize('positronNotebook.assistant.prompt.describe.detail', 'Get an overview of the notebook\'s contents and structure'), + query: 'Can you describe the open notebook for me?', + mode: ChatModeKind.Ask, + iconClass: ThemeIcon.asClassName(Codicon.book) + }, + { + label: localize('positronNotebook.assistant.prompt.comments', 'Add inline comments'), + detail: localize('positronNotebook.assistant.prompt.comments.detail', 'Add explanatory comments to the selected cell(s)'), + query: 'Can you add inline comments to the selected cell(s)?', + mode: ChatModeKind.Edit, + iconClass: ThemeIcon.asClassName(Codicon.commentAdd) + }, + { + label: localize('positronNotebook.assistant.prompt.suggest', 'Suggest next steps'), + detail: localize('positronNotebook.assistant.prompt.suggest.detail', 'Get recommendations for what to do next with this notebook'), + query: 'Can you suggest next steps for this notebook?', + mode: ChatModeKind.Ask, + iconClass: ThemeIcon.asClassName(Codicon.lightbulb) + } +]; + +/** + * Action that opens the assistant chat with predefined prompt options for the notebook. + * Users can select a predefined prompt or type their own custom prompt. + */ +export class AskAssistantAction extends Action2 { + constructor() { + super({ + id: ASK_ASSISTANT_ACTION_ID, + title: localize2('askAssistant', 'Ask Assistant'), + tooltip: localize2('askAssistant.tooltip', 'Ask the assistant about this notebook'), + icon: ThemeIcon.fromId('positron-assistant'), + f1: true, + category: localize2('positronNotebook.category', 'Notebook'), + positronActionBarOptions: { + controlType: 'button', + displayTitle: false + }, + menu: { + id: MenuId.EditorActionsLeft, + group: 'navigation', + order: 50, + when: ContextKeyExpr.equals('activeEditor', POSITRON_NOTEBOOK_EDITOR_ID) + } + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const commandService = accessor.get(ICommandService); + const quickInputService = accessor.get(IQuickInputService); + const notificationService = accessor.get(INotificationService); + const editorService = accessor.get(IEditorService); + + // Get the active notebook instance + const activeNotebook = getNotebookInstanceFromActiveEditorPane(editorService); + if (!activeNotebook) { + return; + } + + // Create and configure the quick pick + const quickPick = quickInputService.createQuickPick(); + quickPick.title = localize('positronNotebook.assistant.quickPick.title', 'Assistant'); + quickPick.description = localize( + 'positronNotebook.assistant.quickPick.description', + 'Type your own prompt or select one of the options below.' + ); + quickPick.placeholder = localize('positronNotebook.assistant.quickPick.placeholder', 'Type your prompt...'); + quickPick.items = ASSISTANT_PREDEFINED_ACTIONS; + quickPick.canSelectMany = false; + + // Wait for user selection or custom input + const result = await new Promise((resolve) => { + quickPick.onDidAccept(() => { + // Check if a predefined item was selected + const selected = quickPick.selectedItems[0]; + const customValue = quickPick.value.trim(); + + if (selected) { + // User selected a predefined prompt item + resolve(selected); + } else if (customValue) { + // User typed a custom prompt - create a temporary item with their input + // Default to 'agent' mode for custom prompts + const customItem: PromptQuickPickItem = { + label: customValue, + query: customValue, + mode: ChatModeKind.Agent + }; + resolve(customItem); + } else { + // No selection and no input + resolve(undefined); + } + quickPick.dispose(); + }); + + quickPick.show(); + + quickPick.onDidHide(() => { + quickPick.dispose(); + resolve(undefined); + }); + }); + + // If user selected an item or typed a custom prompt, execute the chat command + if (result) { + try { + await commandService.executeCommand(CHAT_OPEN_ACTION_ID, { + query: result.query, + mode: result.mode + }); + } catch (error) { + notificationService.error( + localize( + 'positronNotebook.assistant.error', + 'Failed to open assistant chat: {0}', + error instanceof Error ? error.message : String(error) + ) + ); + } + } + } +} + +registerAction2(AskAssistantAction); + diff --git a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts index 41ed6fc4b8ea..96710bdd3f96 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts @@ -43,20 +43,16 @@ import './contrib/undoRedo/positronNotebookUndoRedo.js'; import { registerAction2, MenuId, Action2, IAction2Options, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { ExecuteSelectionInConsoleAction } from './ExecuteSelectionInConsoleAction.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { Codicon } from '../../../../base/common/codicons.js'; import { KernelStatusBadge } from './KernelStatusBadge.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { CHAT_OPEN_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; -import { ChatModeKind } from '../../chat/common/constants.js'; -import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; -import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { UpdateNotebookWorkingDirectoryAction } from './UpdateNotebookWorkingDirectoryAction.js'; import { IPositronNotebookInstance } from './IPositronNotebookInstance.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { getNotebookInstanceFromActiveEditorPane } from './notebookUtils.js'; import { ActiveNotebookHasRunningRuntime } from '../../runtimeNotebookKernel/common/activeRuntimeNotebookContextManager.js'; import { IPositronNotebookCell } from './PositronNotebookCells/IPositronNotebookCell.js'; +import './AskAssistantAction.js'; // Register AskAssistantAction const POSITRON_NOTEBOOK_CATEGORY = localize2('positronNotebook.category', 'Notebook'); @@ -1328,131 +1324,7 @@ registerAction2(class extends NotebookAction2 { }); // Ask Assistant - Opens assistant chat with prompt options for the notebook -registerAction2(class extends NotebookAction2 { - constructor() { - super({ - id: 'positronNotebook.askAssistant', - title: localize2('askAssistant', 'Ask Assistant'), - tooltip: localize2('askAssistant.tooltip', 'Ask the assistant about this notebook'), - icon: ThemeIcon.fromId('positron-assistant'), - f1: true, - category: POSITRON_NOTEBOOK_CATEGORY, - positronActionBarOptions: { - controlType: 'button', - displayTitle: false - }, - menu: { - id: MenuId.EditorActionsLeft, - group: 'navigation', - order: 50, - when: ContextKeyExpr.equals('activeEditor', POSITRON_NOTEBOOK_EDITOR_ID) - } - }); - } - - override async runNotebookAction(_notebook: IPositronNotebookInstance, accessor: ServicesAccessor) { - const commandService = accessor.get(ICommandService); - const quickInputService = accessor.get(IQuickInputService); - const notificationService = accessor.get(INotificationService); - - // Create quick pick items with the three prompt options - interface PromptQuickPickItem extends IQuickPickItem { - query: string; - mode: ChatModeKind; - } - - // Create items array with prompt options and helpful details - const assistantPredefinedActions: PromptQuickPickItem[] = [ - { - label: localize('positronNotebook.assistant.prompt.describe', 'Describe the notebook'), - detail: localize('positronNotebook.assistant.prompt.describe.detail', 'Get an overview of the notebook\'s contents and structure'), - query: 'Can you describe the open notebook for me?', - mode: ChatModeKind.Ask, - iconClass: ThemeIcon.asClassName(Codicon.book) - }, - { - label: localize('positronNotebook.assistant.prompt.comments', 'Add inline comments'), - detail: localize('positronNotebook.assistant.prompt.comments.detail', 'Add explanatory comments to the selected cell(s)'), - query: 'Can you add inline comments to the selected cell(s)?', - mode: ChatModeKind.Edit, - iconClass: ThemeIcon.asClassName(Codicon.commentAdd) - }, - { - label: localize('positronNotebook.assistant.prompt.suggest', 'Suggest next steps'), - detail: localize('positronNotebook.assistant.prompt.suggest.detail', 'Get recommendations for what to do next with this notebook'), - query: 'Can you suggest next steps for this notebook?', - mode: ChatModeKind.Ask, - iconClass: ThemeIcon.asClassName(Codicon.lightbulb) - } - ]; - - // Create the description for the quick pick - const description = localize( - 'positronNotebook.assistant.quickPick.description', - 'Type your own prompt or select one of the options below.' - ); - - // Create and configure the quick pick - const quickPick = quickInputService.createQuickPick(); - quickPick.title = localize('positronNotebook.assistant.quickPick.title', 'Assistant'); - quickPick.description = description; - quickPick.placeholder = localize('positronNotebook.assistant.quickPick.placeholder', 'Type your prompt...'); - quickPick.items = assistantPredefinedActions; - quickPick.canSelectMany = false; - - // Wait for user selection or custom input - const result = await new Promise((resolve) => { - quickPick.onDidAccept(() => { - // Check if a predefined item was selected - const selected = quickPick.selectedItems[0]; - const customValue = quickPick.value.trim(); - - if (selected) { - // User selected a predefined prompt item - resolve(selected); - } else if (customValue) { - // User typed a custom prompt - create a temporary item with their input - // Default to 'agent' mode for custom prompts - const customItem: PromptQuickPickItem = { - label: customValue, - query: customValue, - mode: ChatModeKind.Agent - }; - resolve(customItem); - } else { - // No selection and no input - resolve(undefined); - } - quickPick.dispose(); - }); - - quickPick.show(); - - quickPick.onDidHide(() => { - quickPick.dispose(); - resolve(undefined); - }); - }); - - // If user selected an item or typed a custom prompt, execute the chat command - if (result) { - try { - await commandService.executeCommand(CHAT_OPEN_ACTION_ID, { - query: result.query, - mode: result.mode - }); - } catch (error) { - notificationService.error( - localize( - 'positronNotebook.assistant.error', - 'Failed to open assistant chat: {0}', - error instanceof Error ? error.message : String(error) - ) - ); - } - } - } -}); +// Action is defined in AskAssistantAction.ts // Kernel Status Widget - Shows live kernel connection status at far right of action bar // Widget is self-contained: manages its own menu interactions via ActionBarMenuButton From 5bece06a6c4ab321f453342ed25b5416c5d37080 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 11:40:04 -0500 Subject: [PATCH 07/25] Add AI generated suggestions on request --- .../positron-assistant/src/extension.ts | 18 ++ .../src/md/prompts/notebook/suggestions.md | 113 +++++++ .../src/notebookSuggestions.ts | 277 ++++++++++++++++++ .../browser/AskAssistantAction.ts | 99 ++++++- 4 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 extensions/positron-assistant/src/md/prompts/notebook/suggestions.md create mode 100644 extensions/positron-assistant/src/notebookSuggestions.ts diff --git a/extensions/positron-assistant/src/extension.ts b/extensions/positron-assistant/src/extension.ts index 2763bb37f2aa..717683a1d275 100644 --- a/extensions/positron-assistant/src/extension.ts +++ b/extensions/positron-assistant/src/extension.ts @@ -16,6 +16,7 @@ import { registerCopilotAuthProvider } from './authProvider.js'; import { ALL_DOCUMENTS_SELECTOR, DEFAULT_MAX_TOKEN_OUTPUT } from './constants.js'; import { registerCodeActionProvider } from './codeActions.js'; import { generateCommitMessage } from './git.js'; +import { generateNotebookSuggestions } from './notebookSuggestions.js'; import { TokenUsage, TokenTracker } from './tokens.js'; import { exportChatToUserSpecifiedLocation, exportChatToFileInWorkspace } from './export.js'; import { AnthropicLanguageModel } from './anthropic.js'; @@ -229,6 +230,22 @@ function registerGenerateCommitMessageCommand( ); } +function registerGenerateNotebookSuggestionsCommand( + context: vscode.ExtensionContext, + participantService: ParticipantService, + log: vscode.LogOutputChannel, +) { + context.subscriptions.push( + vscode.commands.registerCommand( + 'positron-assistant.generateNotebookSuggestions', + async (notebookUri: string, token?: vscode.CancellationToken) => { + const cancellationToken = token || new vscode.CancellationTokenSource().token; + return await generateNotebookSuggestions(notebookUri, participantService, log, cancellationToken); + } + ) + ); +} + function registerExportChatCommands(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.commands.registerCommand('positron-assistant.exportChatToFileInWorkspace', async () => { @@ -307,6 +324,7 @@ function registerAssistant(context: vscode.ExtensionContext) { // Commands registerConfigureModelsCommand(context, storage); registerGenerateCommitMessageCommand(context, participantService, log); + registerGenerateNotebookSuggestionsCommand(context, participantService, log); registerExportChatCommands(context); registerToggleInlineCompletionsCommand(context); registerPromptManagement(context); diff --git a/extensions/positron-assistant/src/md/prompts/notebook/suggestions.md b/extensions/positron-assistant/src/md/prompts/notebook/suggestions.md new file mode 100644 index 000000000000..524c8d16bc11 --- /dev/null +++ b/extensions/positron-assistant/src/md/prompts/notebook/suggestions.md @@ -0,0 +1,113 @@ +You are an AI assistant for Jupyter notebooks in Positron. Your task is to analyze the provided notebook context and suggest 3-5 specific, actionable tasks that the user might want to perform with their notebook. + +## Guidelines + +1. **Be Contextual**: Base suggestions on the actual state of the notebook (execution status, errors, outputs, cell content, etc.) +2. **Be Specific**: Suggestions should reference specific aspects of the notebook (e.g., "Debug the error in cell 5" not just "Debug errors") +3. **Be Actionable**: Each suggestion should be something the assistant can help with immediately +4. **Vary Modes**: Use appropriate modes: + - `ask`: For questions, explanations, or information requests + - `edit`: For code modifications, refactoring, or adding content + - `agent`: For complex tasks that may require multiple steps or tool usage +5. **Prioritize Issues**: If there are errors or failed cells, prioritize debugging suggestions +6. **Consider Workflow**: Suggest next logical steps based on what has been executed + +## Output Format + +You MUST return only valid JSON in the output, and nothing else. Format the response as an array of objects with the following structure: + +```json +[ + { + "label": "Brief action title (max 50 chars)", + "detail": "Longer explanation of what this action will do", + "query": "The full prompt that will be sent to the assistant to execute this action", + "mode": "ask" | "edit" | "agent" + } +] +``` + +## Examples + +### Example 1: Notebook with Failed Cell + +Context: Notebook has 10 cells, cell 5 failed with a NameError, 3 cells selected + +```json +[ + { + "label": "Debug the NameError in cell 5", + "detail": "Investigate and fix the undefined variable causing the error", + "query": "Can you help me debug the NameError in cell 5 and suggest a fix?", + "mode": "agent" + }, + { + "label": "Explain the selected cells", + "detail": "Get a detailed explanation of what the selected code does", + "query": "Can you explain what the code in the selected cells does?", + "mode": "ask" + }, + { + "label": "Add error handling", + "detail": "Add try-catch blocks to make the code more robust", + "query": "Can you add error handling to the selected cells?", + "mode": "edit" + } +] +``` + +### Example 2: Empty Notebook + +Context: Notebook has 0 cells, Python kernel + +```json +[ + { + "label": "Get started with data analysis", + "detail": "Create a basic data analysis workflow with pandas", + "query": "Can you help me set up a basic data analysis workflow with pandas? Please create cells for loading data, exploring it, and visualizing it.", + "mode": "agent" + }, + { + "label": "Create a data science template", + "detail": "Set up a standard data science notebook structure", + "query": "Can you create a template notebook structure for data science work with sections for imports, data loading, exploration, modeling, and conclusions?", + "mode": "edit" + } +] +``` + +### Example 3: Notebook with Outputs + +Context: Notebook has 15 cells, all executed successfully, last cell shows a matplotlib plot, 0 cells selected + +```json +[ + { + "label": "Explain the visualization", + "detail": "Get insights about the plot in the last cell", + "query": "Can you explain what the visualization in the last cell shows and what insights we can draw from it?", + "mode": "ask" + }, + { + "label": "Improve the plot aesthetics", + "detail": "Enhance the visual appearance of the matplotlib plot", + "query": "Can you suggest improvements to make the plot in the last cell more visually appealing and publication-ready?", + "mode": "edit" + }, + { + "label": "Add summary statistics", + "detail": "Create a new cell with statistical analysis of the plotted data", + "query": "Can you add a cell that calculates and displays summary statistics for the data shown in the plot?", + "mode": "agent" + }, + { + "label": "Export results to file", + "detail": "Save the plot and data to files", + "query": "Can you help me export the visualization and underlying data to files?", + "mode": "agent" + } +] +``` + +Remember: Return ONLY valid JSON. Do not include any explanatory text, markdown formatting, or additional commentary. diff --git a/extensions/positron-assistant/src/notebookSuggestions.ts b/extensions/positron-assistant/src/notebookSuggestions.ts new file mode 100644 index 000000000000..a71a21fec02c --- /dev/null +++ b/extensions/positron-assistant/src/notebookSuggestions.ts @@ -0,0 +1,277 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as positron from 'positron'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { ParticipantService } from './participants.js'; +import { MARKDOWN_DIR } from './constants'; + +/** + * Interface for notebook action suggestions returned to the workbench + */ +export interface NotebookActionSuggestion { + label: string; + detail?: string; + query: string; + mode: 'ask' | 'edit' | 'agent'; + iconClass?: string; +} + +/** + * Generate AI-powered action suggestions based on notebook context + * @param notebookUri URI of the notebook to analyze + * @param participantService Service for accessing the current chat model + * @param log Log output channel for debugging + * @param token Cancellation token + * @returns Array of suggested actions + */ +export async function generateNotebookSuggestions( + notebookUri: string, + participantService: ParticipantService, + log: vscode.LogOutputChannel, + token: vscode.CancellationToken +): Promise { + log.info(`[notebook-suggestions] Generating suggestions for notebook: ${notebookUri}`); + + // Get the model to use for generation + const model = await getModel(participantService); + log.info(`[notebook-suggestions] Using model (${model.vendor}) ${model.id}`); + + // Get notebook context + const context = await positron.notebooks.getContext(); + if (!context) { + log.warn('[notebook-suggestions] No notebook context available'); + return []; + } + + // Get all cells if not already included in context + const allCells = context.allCells || await positron.notebooks.getCells(notebookUri); + + // Build context summary for the prompt + const contextSummary = buildContextSummary(context, allCells); + log.trace(`[notebook-suggestions] Context summary:\n${contextSummary}`); + + // Load the system prompt template + const systemPrompt = await fs.promises.readFile( + path.join(MARKDOWN_DIR, 'prompts', 'notebook', 'suggestions.md'), + 'utf8' + ); + + try { + // Send request to LLM + const response = await model.sendRequest([ + new vscode.LanguageModelChatMessage( + vscode.LanguageModelChatMessageRole.System, + systemPrompt + ), + vscode.LanguageModelChatMessage.User(contextSummary) + ], {}, token); + + // Accumulate the response + let jsonResponse = ''; + for await (const delta of response.text) { + if (token.isCancellationRequested) { + log.info('[notebook-suggestions] Generation cancelled by user'); + return []; + } + jsonResponse += delta; + } + + log.trace(`[notebook-suggestions] Raw LLM response:\n${jsonResponse}`); + + // Parse and validate the JSON response + const suggestions = parseAndValidateSuggestions(jsonResponse, log); + log.info(`[notebook-suggestions] Generated ${suggestions.length} suggestions`); + + return suggestions; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error(`[notebook-suggestions] Error generating suggestions: ${errorMessage}`); + + // Return empty array rather than throwing to allow graceful degradation + vscode.window.showWarningMessage( + `Failed to generate AI suggestions: ${errorMessage}. Please try again or use predefined actions.` + ); + return []; + } +} + +/** + * Build a context summary string from notebook context + */ +function buildContextSummary( + context: positron.notebooks.NotebookContext, + allCells: positron.notebooks.NotebookCell[] +): string { + const parts: string[] = []; + + // Basic info + parts.push(`Notebook: ${context.uri}`); + parts.push(`Kernel Language: ${context.kernelLanguage || 'unknown'}`); + parts.push(`Total Cells: ${context.cellCount}`); + parts.push(`Selected Cells: ${context.selectedCells.length}`); + + // Cell type breakdown + const codeCells = allCells.filter(c => c.type === positron.notebooks.NotebookCellType.Code); + const markdownCells = allCells.filter(c => c.type === positron.notebooks.NotebookCellType.Markdown); + parts.push(`Code Cells: ${codeCells.length}`); + parts.push(`Markdown Cells: ${markdownCells.length}`); + + // Execution status + const executedCells = codeCells.filter(c => c.executionOrder !== undefined); + const failedCells = codeCells.filter(c => c.lastRunSuccess === false); + const cellsWithOutput = allCells.filter(c => c.hasOutput); + parts.push(`Executed Cells: ${executedCells.length}`); + parts.push(`Failed Cells: ${failedCells.length}`); + parts.push(`Cells with Output: ${cellsWithOutput.length}`); + + // Selected cell content (if any) + if (context.selectedCells.length > 0) { + parts.push('\n## Selected Cells:'); + context.selectedCells.forEach(cell => { + parts.push(`\n### Cell ${cell.index} (${cell.type})`); + if (cell.type === positron.notebooks.NotebookCellType.Code) { + parts.push(`Status: ${cell.executionStatus || 'not executed'}`); + if (cell.lastRunSuccess !== undefined) { + parts.push(`Last Run: ${cell.lastRunSuccess ? 'success' : 'failed'}`); + } + } + // Include a snippet of the content (first 200 characters) + const contentSnippet = cell.content.substring(0, 200); + parts.push(`Content: ${contentSnippet}${cell.content.length > 200 ? '...' : ''}`); + }); + } + + // Recent cells (last 3 executed cells if no selection) + if (context.selectedCells.length === 0 && executedCells.length > 0) { + const recentCells = executedCells + .sort((a, b) => (b.executionOrder || 0) - (a.executionOrder || 0)) + .slice(0, 3); + + parts.push('\n## Recently Executed Cells:'); + recentCells.forEach(cell => { + parts.push(`\n### Cell ${cell.index}`); + parts.push(`Status: ${cell.executionStatus || 'completed'}`); + parts.push(`Success: ${cell.lastRunSuccess ? 'yes' : 'no'}`); + const contentSnippet = cell.content.substring(0, 150); + parts.push(`Content: ${contentSnippet}${cell.content.length > 150 ? '...' : ''}`); + }); + } + + return parts.join('\n'); +} + +/** + * Parse and validate the LLM response as JSON suggestions + */ +function parseAndValidateSuggestions( + jsonResponse: string, + log: vscode.LogOutputChannel +): NotebookActionSuggestion[] { + try { + // Extract JSON from potential markdown code blocks + let jsonString = jsonResponse.trim(); + + // Remove markdown code fence if present + const codeBlockMatch = jsonString.match(/```(?:json)?\s*([\s\S]*?)```/); + if (codeBlockMatch) { + jsonString = codeBlockMatch[1].trim(); + } + + // Parse the JSON + const parsed = JSON.parse(jsonString); + + // Ensure it's an array + const suggestions = Array.isArray(parsed) ? parsed : [parsed]; + + // Validate and normalize each suggestion + return suggestions + .filter(s => validateSuggestion(s, log)) + .map(s => normalizeSuggestion(s)) + .slice(0, 5); // Limit to 5 suggestions + + } catch (error) { + log.error(`[notebook-suggestions] Failed to parse LLM response as JSON: ${error}`); + log.trace(`[notebook-suggestions] Attempted to parse: ${jsonResponse}`); + return []; + } +} + +/** + * Validate that a suggestion object has required fields + */ +function validateSuggestion(suggestion: any, log: vscode.LogOutputChannel): boolean { + if (!suggestion || typeof suggestion !== 'object') { + log.warn('[notebook-suggestions] Invalid suggestion: not an object'); + return false; + } + + if (!suggestion.label || typeof suggestion.label !== 'string') { + log.warn('[notebook-suggestions] Invalid suggestion: missing or invalid label'); + return false; + } + + if (!suggestion.query || typeof suggestion.query !== 'string') { + log.warn('[notebook-suggestions] Invalid suggestion: missing or invalid query'); + return false; + } + + return true; +} + +/** + * Normalize a suggestion object to match the expected interface + */ +function normalizeSuggestion(suggestion: any): NotebookActionSuggestion { + // Normalize mode to valid values + let mode: 'ask' | 'edit' | 'agent' = 'agent'; + if (suggestion.mode === 'ask' || suggestion.mode === 'edit' || suggestion.mode === 'agent') { + mode = suggestion.mode; + } + + return { + label: suggestion.label, + detail: suggestion.detail || undefined, + query: suggestion.query, + mode, + iconClass: suggestion.iconClass || undefined + }; +} + +/** + * Get the language model to use for generation + * Follows the same pattern as git.ts + */ +async function getModel(participantService: ParticipantService): Promise { + // Check for the latest chat session and use its model + const sessionModelId = participantService.getCurrentSessionModel(); + if (sessionModelId) { + const models = await vscode.lm.selectChatModels({ 'id': sessionModelId }); + if (models && models.length > 0) { + return models[0]; + } + } + + // Fall back to the first model for the currently selected provider + const currentProvider = await positron.ai.getCurrentProvider(); + if (currentProvider) { + const models = await vscode.lm.selectChatModels({ vendor: currentProvider.id }); + if (models && models.length > 0) { + return models[0]; + } + } + + // Fall back to any available model + const [firstModel] = await vscode.lm.selectChatModels(); + if (!firstModel) { + throw new Error('No language model available'); + } + + return firstModel; +} diff --git a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts index 62f9bc0f88db..71126d3338a6 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts @@ -9,7 +9,7 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; -import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; @@ -26,6 +26,8 @@ const ASK_ASSISTANT_ACTION_ID = 'positronNotebook.askAssistant'; interface PromptQuickPickItem extends IQuickPickItem { query: string; mode: ChatModeKind; + /** Flag to indicate this item should trigger AI suggestion generation */ + generateSuggestions?: boolean; } /** @@ -52,6 +54,14 @@ const ASSISTANT_PREDEFINED_ACTIONS: PromptQuickPickItem[] = [ query: 'Can you suggest next steps for this notebook?', mode: ChatModeKind.Ask, iconClass: ThemeIcon.asClassName(Codicon.lightbulb) + }, + { + label: localize('positronNotebook.assistant.prompt.generateSuggestions', '✨ Generate AI suggestions...'), + detail: localize('positronNotebook.assistant.prompt.generateSuggestions.detail', 'Let AI analyze your notebook and suggest relevant actions'), + query: '', // Will be generated by AI + mode: ChatModeKind.Agent, + iconClass: ThemeIcon.asClassName(Codicon.sparkle), + generateSuggestions: true } ]; @@ -93,7 +103,7 @@ export class AskAssistantAction extends Action2 { return; } - // Create and configure the quick pick + // Create and configure the quick pick (items can include separators) const quickPick = quickInputService.createQuickPick(); quickPick.title = localize('positronNotebook.assistant.quickPick.title', 'Assistant'); quickPick.description = localize( @@ -104,6 +114,29 @@ export class AskAssistantAction extends Action2 { quickPick.items = ASSISTANT_PREDEFINED_ACTIONS; quickPick.canSelectMany = false; + // Handle accept with veto pattern for AI generation + quickPick.onWillAccept((e) => { + const selected = quickPick.selectedItems[0]; + + // Check if "Generate AI suggestions" was selected (type guard for PromptQuickPickItem) + if (selected && 'generateSuggestions' in selected && selected.generateSuggestions) { + e.veto(); // Prevent the quick pick from closing + + // Generate suggestions and update the quick pick in place + this.handleGenerateSuggestions( + quickPick, + activeNotebook, + commandService, + notificationService + ).catch(() => { + // Reset state on error + quickPick.busy = false; + quickPick.enabled = true; + quickPick.placeholder = localize('positronNotebook.assistant.quickPick.placeholder', 'Type your prompt...'); + }); + } + }); + // Wait for user selection or custom input const result = await new Promise((resolve) => { quickPick.onDidAccept(() => { @@ -140,6 +173,7 @@ export class AskAssistantAction extends Action2 { // If user selected an item or typed a custom prompt, execute the chat command if (result) { + // Execute the selected/custom prompt (generation suggestions are handled above via veto) try { await commandService.executeCommand(CHAT_OPEN_ACTION_ID, { query: result.query, @@ -156,6 +190,67 @@ export class AskAssistantAction extends Action2 { } } } + + /** + * Handle AI-generated suggestion generation + * Updates the quick pick in place with generated suggestions + */ + private async handleGenerateSuggestions( + quickPick: IQuickPick, + notebook: any, + commandService: ICommandService, + notificationService: INotificationService + ): Promise { + // Update quick pick to show loading state + quickPick.busy = true; + quickPick.enabled = false; + quickPick.placeholder = localize('positronNotebook.assistant.generating.placeholder', 'Generating AI suggestions...'); + + try { + // Call extension command to generate suggestions + const suggestions = await commandService.executeCommand( + 'positron-assistant.generateNotebookSuggestions', + notebook.uri.toString() + ); + + if (!suggestions || suggestions.length === 0) { + notificationService.info( + localize( + 'positronNotebook.assistant.noSuggestions', + 'No suggestions generated. Try selecting cells or executing code first.' + ) + ); + return; + } + + // Update quick pick items to show predefined actions first, then AI suggestions below + const separator: IQuickPickSeparator = { + type: 'separator', + label: localize('positronNotebook.assistant.aiSuggestions', 'AI-Generated Suggestions') + }; + + quickPick.items = [ + ...ASSISTANT_PREDEFINED_ACTIONS.filter(item => !item.generateSuggestions), + separator as any, + ...suggestions + ]; + + quickPick.placeholder = localize('positronNotebook.assistant.selectAction', 'Select an action'); + + } catch (error) { + notificationService.error( + localize( + 'positronNotebook.assistant.generateError', + 'Failed to generate suggestions: {0}', + error instanceof Error ? error.message : String(error) + ) + ); + } finally { + // Reset busy state + quickPick.busy = false; + quickPick.enabled = true; + } + } } registerAction2(AskAssistantAction); From 0372e2a6c0a5f0ab4a4b3e53904a0b07987c998b Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 12:00:53 -0500 Subject: [PATCH 08/25] Enhance AskAssistantAction to include loading state and improved error handling for AI suggestions. Added conditional context for activation and ensured quick pick items are restored correctly after suggestions generation. --- .../quickinput/browser/media/quickInput.css | 8 ++++ .../browser/AskAssistantAction.ts | 42 +++++++++++++++---- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index ed4aa50f04a0..00c26fee865c 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -237,6 +237,14 @@ align-items: center; justify-content: center; } +/* --- Start Positron --- */ +/* Fix wobbling spinner animation by ensuring rotation happens around center */ +.quick-input-list .quick-input-list-icon.codicon-loading.codicon-modifier-spin { + transform-origin: center; + width: 22px; + padding-right: 0; +} +/* --- End Positron --- */ .quick-input-list .quick-input-list-rows { overflow: hidden; diff --git a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts index 71126d3338a6..dc1649ee98da 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts @@ -86,7 +86,10 @@ export class AskAssistantAction extends Action2 { id: MenuId.EditorActionsLeft, group: 'navigation', order: 50, - when: ContextKeyExpr.equals('activeEditor', POSITRON_NOTEBOOK_EDITOR_ID) + when: ContextKeyExpr.and( + ContextKeyExpr.equals('activeEditor', POSITRON_NOTEBOOK_EDITOR_ID), + ContextKeyExpr.has('config.positron.assistant.notebookMode.enable') + ) } }); } @@ -129,10 +132,14 @@ export class AskAssistantAction extends Action2 { commandService, notificationService ).catch(() => { - // Reset state on error + // Reset state on error (handleGenerateSuggestions already handles item cleanup) quickPick.busy = false; quickPick.enabled = true; quickPick.placeholder = localize('positronNotebook.assistant.quickPick.placeholder', 'Type your prompt...'); + // Ensure items are restored if handleGenerateSuggestions didn't complete + if (quickPick.items.length === 0 || (quickPick.items[0] as PromptQuickPickItem).pickable === false) { + quickPick.items = ASSISTANT_PREDEFINED_ACTIONS.filter(item => !item.generateSuggestions); + } }); } }); @@ -201,11 +208,32 @@ export class AskAssistantAction extends Action2 { commandService: ICommandService, notificationService: INotificationService ): Promise { + // Create a loading item with animated spinner icon + const loadingItem: PromptQuickPickItem = { + label: localize('positronNotebook.assistant.generating.label', 'Generating AI suggestions...'), + query: '', // Empty query since this is not selectable + mode: ChatModeKind.Agent, + iconClass: ThemeIcon.asClassName(ThemeIcon.modify(Codicon.loading, 'spin')), + pickable: false // Prevent selection of the loading item + }; + // Update quick pick to show loading state quickPick.busy = true; quickPick.enabled = false; quickPick.placeholder = localize('positronNotebook.assistant.generating.placeholder', 'Generating AI suggestions...'); + // Add loading item in the AI-generated suggestions section (beneath predefined items) + const separator: IQuickPickSeparator = { + type: 'separator', + label: localize('positronNotebook.assistant.aiSuggestions', 'AI-Generated Suggestions') + }; + + quickPick.items = [ + ...ASSISTANT_PREDEFINED_ACTIONS.filter(item => !item.generateSuggestions), + separator as any, + loadingItem + ]; + try { // Call extension command to generate suggestions const suggestions = await commandService.executeCommand( @@ -220,15 +248,13 @@ export class AskAssistantAction extends Action2 { 'No suggestions generated. Try selecting cells or executing code first.' ) ); + // Remove loading item and separator, restore original items + quickPick.items = ASSISTANT_PREDEFINED_ACTIONS.filter(item => !item.generateSuggestions); return; } // Update quick pick items to show predefined actions first, then AI suggestions below - const separator: IQuickPickSeparator = { - type: 'separator', - label: localize('positronNotebook.assistant.aiSuggestions', 'AI-Generated Suggestions') - }; - + // Replace the loading item with actual suggestions quickPick.items = [ ...ASSISTANT_PREDEFINED_ACTIONS.filter(item => !item.generateSuggestions), separator as any, @@ -245,6 +271,8 @@ export class AskAssistantAction extends Action2 { error instanceof Error ? error.message : String(error) ) ); + // Remove loading item and separator, restore original items on error + quickPick.items = ASSISTANT_PREDEFINED_ACTIONS.filter(item => !item.generateSuggestions); } finally { // Reset busy state quickPick.busy = false; From e02643a7916b66334a77104641397ad3a15fb541 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 12:35:35 -0500 Subject: [PATCH 09/25] Add cancelization token to avoid completing assistant request when the user closes the quickpick --- .../browser/AskAssistantAction.ts | 64 +++++++++++++++---- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts index dc1649ee98da..3bf479527894 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts @@ -12,6 +12,8 @@ import { INotificationService } from '../../../../platform/notification/common/n import { IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { isCancellationError } from '../../../../base/common/errors.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { CHAT_OPEN_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; import { ChatModeKind } from '../../chat/common/constants.js'; @@ -106,6 +108,10 @@ export class AskAssistantAction extends Action2 { return; } + // Create cancellation token source for AI generation requests (In case + // the user closes the quick pick before the suggestions are generated) + const cancellationTokenSource = new CancellationTokenSource(); + // Create and configure the quick pick (items can include separators) const quickPick = quickInputService.createQuickPick(); quickPick.title = localize('positronNotebook.assistant.quickPick.title', 'Assistant'); @@ -130,7 +136,8 @@ export class AskAssistantAction extends Action2 { quickPick, activeNotebook, commandService, - notificationService + notificationService, + cancellationTokenSource.token ).catch(() => { // Reset state on error (handleGenerateSuggestions already handles item cleanup) quickPick.busy = false; @@ -173,11 +180,16 @@ export class AskAssistantAction extends Action2 { quickPick.show(); quickPick.onDidHide(() => { + // Cancel any ongoing AI generation when the quick pick is hidden + cancellationTokenSource.cancel(); + cancellationTokenSource.dispose(); quickPick.dispose(); resolve(undefined); }); }); + cancellationTokenSource.dispose(); + // If user selected an item or typed a custom prompt, execute the chat command if (result) { // Execute the selected/custom prompt (generation suggestions are handled above via veto) @@ -199,14 +211,30 @@ export class AskAssistantAction extends Action2 { } /** - * Handle AI-generated suggestion generation - * Updates the quick pick in place with generated suggestions + * Handle AI-generated suggestion generation for the notebook. + * + * This method updates the quick pick in place to show a loading state, then calls + * the extension command to generate AI suggestions based on the notebook context. + * The generated suggestions are displayed in the quick pick below the predefined actions. + * + * If cancellation is requested (e.g., user closes the quick pick), the request is + * cancelled and the quick pick is restored to its original state without showing + * error notifications. + * + * @param quickPick The quick pick instance to update with generated suggestions + * @param notebook The active notebook instance to analyze for suggestions + * @param commandService Service for executing extension commands + * @param notificationService Service for displaying notifications to the user + * @param token Cancellation token that will be cancelled if the user closes the quick pick. + * The extension command uses this token to cancel ongoing LLM requests. + * @returns Promise that resolves when suggestions are generated or cancelled */ private async handleGenerateSuggestions( quickPick: IQuickPick, notebook: any, commandService: ICommandService, - notificationService: INotificationService + notificationService: INotificationService, + token: CancellationToken ): Promise { // Create a loading item with animated spinner icon const loadingItem: PromptQuickPickItem = { @@ -238,9 +266,18 @@ export class AskAssistantAction extends Action2 { // Call extension command to generate suggestions const suggestions = await commandService.executeCommand( 'positron-assistant.generateNotebookSuggestions', - notebook.uri.toString() + notebook.uri.toString(), + token ); + // Check if cancellation was requested during the call + // The extension may return an empty array instead of throwing on cancellation + if (token.isCancellationRequested) { + // Remove loading item and separator, restore original items + quickPick.items = ASSISTANT_PREDEFINED_ACTIONS.filter(item => !item.generateSuggestions); + return; + } + if (!suggestions || suggestions.length === 0) { notificationService.info( localize( @@ -264,13 +301,16 @@ export class AskAssistantAction extends Action2 { quickPick.placeholder = localize('positronNotebook.assistant.selectAction', 'Select an action'); } catch (error) { - notificationService.error( - localize( - 'positronNotebook.assistant.generateError', - 'Failed to generate suggestions: {0}', - error instanceof Error ? error.message : String(error) - ) - ); + // Don't show error notification for cancellation errors (user closed the pick) + if (!isCancellationError(error)) { + notificationService.error( + localize( + 'positronNotebook.assistant.generateError', + 'Failed to generate suggestions: {0}', + error instanceof Error ? error.message : String(error) + ) + ); + } // Remove loading item and separator, restore original items on error quickPick.items = ASSISTANT_PREDEFINED_ACTIONS.filter(item => !item.generateSuggestions); } finally { From 28091836696de663cc0262ca5260c7e55a8daf02 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 12:37:04 -0500 Subject: [PATCH 10/25] fix bad typing --- .../positronNotebook/browser/AskAssistantAction.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts index 3bf479527894..8fa659cedc90 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts @@ -19,6 +19,7 @@ import { CHAT_OPEN_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; import { ChatModeKind } from '../../chat/common/constants.js'; import { POSITRON_NOTEBOOK_EDITOR_ID } from '../common/positronNotebookCommon.js'; import { getNotebookInstanceFromActiveEditorPane } from './notebookUtils.js'; +import { IPositronNotebookInstance } from './IPositronNotebookInstance.js'; const ASK_ASSISTANT_ACTION_ID = 'positronNotebook.askAssistant'; @@ -113,7 +114,7 @@ export class AskAssistantAction extends Action2 { const cancellationTokenSource = new CancellationTokenSource(); // Create and configure the quick pick (items can include separators) - const quickPick = quickInputService.createQuickPick(); + const quickPick = quickInputService.createQuickPick({ useSeparators: true }); quickPick.title = localize('positronNotebook.assistant.quickPick.title', 'Assistant'); quickPick.description = localize( 'positronNotebook.assistant.quickPick.description', @@ -230,8 +231,8 @@ export class AskAssistantAction extends Action2 { * @returns Promise that resolves when suggestions are generated or cancelled */ private async handleGenerateSuggestions( - quickPick: IQuickPick, - notebook: any, + quickPick: IQuickPick, + notebook: IPositronNotebookInstance, commandService: ICommandService, notificationService: INotificationService, token: CancellationToken @@ -258,7 +259,7 @@ export class AskAssistantAction extends Action2 { quickPick.items = [ ...ASSISTANT_PREDEFINED_ACTIONS.filter(item => !item.generateSuggestions), - separator as any, + separator, loadingItem ]; @@ -294,7 +295,7 @@ export class AskAssistantAction extends Action2 { // Replace the loading item with actual suggestions quickPick.items = [ ...ASSISTANT_PREDEFINED_ACTIONS.filter(item => !item.generateSuggestions), - separator as any, + separator, ...suggestions ]; From b836c451c8ee2de3d23cda228999b3de4b5791ef Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 12:43:36 -0500 Subject: [PATCH 11/25] Update assistant action to use NotebookAction2 and make NotebookAction2 async aware for run action method --- .../browser/AskAssistantAction.ts | 18 +++-------- .../browser/NotebookAction2.ts | 32 +++++++++++++++++++ .../browser/positronNotebook.contribution.ts | 17 +--------- 3 files changed, 38 insertions(+), 29 deletions(-) create mode 100644 src/vs/workbench/contrib/positronNotebook/browser/NotebookAction2.ts diff --git a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts index 8fa659cedc90..5ac14f8eda1e 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize, localize2 } from '../../../../nls.js'; -import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -14,12 +14,11 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { isCancellationError } from '../../../../base/common/errors.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; import { CHAT_OPEN_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; import { ChatModeKind } from '../../chat/common/constants.js'; import { POSITRON_NOTEBOOK_EDITOR_ID } from '../common/positronNotebookCommon.js'; -import { getNotebookInstanceFromActiveEditorPane } from './notebookUtils.js'; import { IPositronNotebookInstance } from './IPositronNotebookInstance.js'; +import { NotebookAction2 } from './NotebookAction2.js'; const ASK_ASSISTANT_ACTION_ID = 'positronNotebook.askAssistant'; @@ -72,7 +71,7 @@ const ASSISTANT_PREDEFINED_ACTIONS: PromptQuickPickItem[] = [ * Action that opens the assistant chat with predefined prompt options for the notebook. * Users can select a predefined prompt or type their own custom prompt. */ -export class AskAssistantAction extends Action2 { +export class AskAssistantAction extends NotebookAction2 { constructor() { super({ id: ASK_ASSISTANT_ACTION_ID, @@ -97,17 +96,10 @@ export class AskAssistantAction extends Action2 { }); } - override async run(accessor: ServicesAccessor): Promise { + override async runNotebookAction(notebook: IPositronNotebookInstance, accessor: ServicesAccessor): Promise { const commandService = accessor.get(ICommandService); const quickInputService = accessor.get(IQuickInputService); const notificationService = accessor.get(INotificationService); - const editorService = accessor.get(IEditorService); - - // Get the active notebook instance - const activeNotebook = getNotebookInstanceFromActiveEditorPane(editorService); - if (!activeNotebook) { - return; - } // Create cancellation token source for AI generation requests (In case // the user closes the quick pick before the suggestions are generated) @@ -135,7 +127,7 @@ export class AskAssistantAction extends Action2 { // Generate suggestions and update the quick pick in place this.handleGenerateSuggestions( quickPick, - activeNotebook, + notebook, commandService, notificationService, cancellationTokenSource.token diff --git a/src/vs/workbench/contrib/positronNotebook/browser/NotebookAction2.ts b/src/vs/workbench/contrib/positronNotebook/browser/NotebookAction2.ts new file mode 100644 index 000000000000..fab9fb9c34df --- /dev/null +++ b/src/vs/workbench/contrib/positronNotebook/browser/NotebookAction2.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024-2025 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Action2 } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IPositronNotebookInstance } from './IPositronNotebookInstance.js'; +import { getNotebookInstanceFromActiveEditorPane } from './notebookUtils.js'; + +/** + * Base class for notebook-level actions that operate on IPositronNotebookInstance. + * Automatically gets the active notebook instance and passes it to the runNotebookAction method. + */ +export abstract class NotebookAction2 extends Action2 { + override async run(accessor: ServicesAccessor, ...args: any[]): Promise { + const editorService = accessor.get(IEditorService); + const activeNotebook = getNotebookInstanceFromActiveEditorPane(editorService); + if (!activeNotebook) { + return; + } + const result = this.runNotebookAction(activeNotebook, accessor); + // Handle both sync (void) and async (Promise) returns + if (result instanceof Promise) { + await result; + } + } + + protected abstract runNotebookAction(notebook: IPositronNotebookInstance, accessor: ServicesAccessor): Promise | void; +} + diff --git a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts index 96710bdd3f96..a72aedf9f5f8 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/positronNotebook.contribution.ts @@ -52,6 +52,7 @@ import { IEditorService } from '../../../services/editor/common/editorService.js import { getNotebookInstanceFromActiveEditorPane } from './notebookUtils.js'; import { ActiveNotebookHasRunningRuntime } from '../../runtimeNotebookKernel/common/activeRuntimeNotebookContextManager.js'; import { IPositronNotebookCell } from './PositronNotebookCells/IPositronNotebookCell.js'; +import { NotebookAction2 } from './NotebookAction2.js'; import './AskAssistantAction.js'; // Register AskAssistantAction const POSITRON_NOTEBOOK_CATEGORY = localize2('positronNotebook.category', 'Notebook'); @@ -337,22 +338,6 @@ Registry.as(EditorExtensions.EditorFactory).registerEdit PositronNotebookEditorSerializer ); -/** - * Base class for notebook-level actions that operate on IPositronNotebookInstance. - * Automatically gets the active notebook instance and passes it to the _run method. - */ -abstract class NotebookAction2 extends Action2 { - override run(accessor: ServicesAccessor, ...args: any[]): void { - const editorService = accessor.get(IEditorService); - const activeNotebook = getNotebookInstanceFromActiveEditorPane(editorService); - if (!activeNotebook) { - return; - } - this.runNotebookAction(activeNotebook, accessor); - } - - protected abstract runNotebookAction(notebook: IPositronNotebookInstance, accessor: ServicesAccessor): any; -} //#region Notebook Commands registerAction2(class extends NotebookAction2 { From b18ff84b5904045a54abe13a7f5351f4b51bd833 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 12:47:11 -0500 Subject: [PATCH 12/25] Add common-sense limit to custom prompt length --- .../browser/AskAssistantAction.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts index 5ac14f8eda1e..a2970aecf3c1 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts @@ -22,6 +22,13 @@ import { NotebookAction2 } from './NotebookAction2.js'; const ASK_ASSISTANT_ACTION_ID = 'positronNotebook.askAssistant'; +/** + * Maximum allowed length for custom prompts in characters. + * Set to 15,000 characters (approximately 2-3 pages of text) to prevent abuse + * while still allowing legitimate use cases. + */ +const MAX_CUSTOM_PROMPT_LENGTH = 15000; + /** * Interface for quick pick items that represent assistant prompt options */ @@ -116,10 +123,11 @@ export class AskAssistantAction extends NotebookAction2 { quickPick.items = ASSISTANT_PREDEFINED_ACTIONS; quickPick.canSelectMany = false; - // Handle accept with veto pattern for AI generation + // Handle accept with veto pattern for AI generation and custom prompt validation quickPick.onWillAccept((e) => { const selected = quickPick.selectedItems[0]; + // Check if "Generate AI suggestions" was selected (type guard for PromptQuickPickItem) if (selected && 'generateSuggestions' in selected && selected.generateSuggestions) { e.veto(); // Prevent the quick pick from closing @@ -141,6 +149,21 @@ export class AskAssistantAction extends NotebookAction2 { quickPick.items = ASSISTANT_PREDEFINED_ACTIONS.filter(item => !item.generateSuggestions); } }); + return; + } + + const customValue = quickPick.value.trim(); + + // Validate custom prompt length if no predefined item is selected + if (!selected && customValue && customValue.length > MAX_CUSTOM_PROMPT_LENGTH) { + e.veto(); // Prevent the quick pick from closing + notificationService.error( + localize( + 'positronNotebook.assistant.prompt.tooLong', + 'Custom prompt is too long. Maximum length is {0} characters. Please shorten your prompt or switch to the chat pane directly.', + MAX_CUSTOM_PROMPT_LENGTH + ) + ); } }); From bb54ef4e313880268c052754a72001a0dc34e99f Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 12:50:41 -0500 Subject: [PATCH 13/25] Remove second ai sparkle --- .../contrib/positronNotebook/browser/AskAssistantAction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts index a2970aecf3c1..27b0374a66ff 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts @@ -65,7 +65,7 @@ const ASSISTANT_PREDEFINED_ACTIONS: PromptQuickPickItem[] = [ iconClass: ThemeIcon.asClassName(Codicon.lightbulb) }, { - label: localize('positronNotebook.assistant.prompt.generateSuggestions', '✨ Generate AI suggestions...'), + label: localize('positronNotebook.assistant.prompt.generateSuggestions', 'Generate AI suggestions...'), detail: localize('positronNotebook.assistant.prompt.generateSuggestions.detail', 'Let AI analyze your notebook and suggest relevant actions'), query: '', // Will be generated by AI mode: ChatModeKind.Agent, From 08a330805b4677839613172dd8fb0942213740d0 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 14:13:19 -0500 Subject: [PATCH 14/25] Centralized notebook context serialization so filtering and XML generation are consistent across chat pane, suggestions, and inline chat. Also reduced unneccesary repetition of notebook serialization for each recursive prompt generation call. --- extensions/positron-assistant/src/api.ts | 6 +- .../src/notebookContextFilter.ts | 40 +++ .../src/notebookSuggestions.ts | 103 ++---- .../positron-assistant/src/participants.ts | 89 ++---- .../positron-assistant/src/promptRender.ts | 42 +-- .../src/tools/notebookUtils.ts | 292 ++++++++++++++++++ 6 files changed, 390 insertions(+), 182 deletions(-) diff --git a/extensions/positron-assistant/src/api.ts b/extensions/positron-assistant/src/api.ts index cd0d2d0f480f..61f04088c196 100644 --- a/extensions/positron-assistant/src/api.ts +++ b/extensions/positron-assistant/src/api.ts @@ -6,7 +6,8 @@ import * as xml from './xml.js'; import * as vscode from 'vscode'; import * as positron from 'positron'; -import { getAttachedNotebookContext, isStreamingEditsEnabled, ParticipantID } from './participants.js'; +import { isStreamingEditsEnabled, ParticipantID } from './participants.js'; +import { hasAttachedNotebookContext } from './tools/notebookUtils.js'; import { MARKDOWN_DIR, TOOL_TAG_REQUIRES_ACTIVE_SESSION, TOOL_TAG_REQUIRES_WORKSPACE, TOOL_TAG_REQUIRES_NOTEBOOK } from './constants.js'; import { isWorkspaceOpen } from './utils.js'; import { PositronAssistantToolName } from './types.js'; @@ -174,8 +175,7 @@ export async function getEnabledTools( } // Check if a notebook is attached as context and has an active editor - const notebookContext = await getAttachedNotebookContext(request); - const hasActiveNotebook = !!notebookContext; + const hasActiveNotebook = await hasAttachedNotebookContext(request); // Define more readable variables for filtering. const inChatPane = request.location2 === undefined; diff --git a/extensions/positron-assistant/src/notebookContextFilter.ts b/extensions/positron-assistant/src/notebookContextFilter.ts index 9d8cd60aa11d..08f2657532a7 100644 --- a/extensions/positron-assistant/src/notebookContextFilter.ts +++ b/extensions/positron-assistant/src/notebookContextFilter.ts @@ -80,3 +80,43 @@ export function filterNotebookContext( allCells: filteredCells }; } + +/** + * Determines which cells to include in context, handling fallback cases. + * + * This function extends `filterNotebookContext` by providing a fallback strategy + * for cases where `allCells` is undefined (large notebooks without selection). + * In such cases, it uses a sliding window around recent executed cells. + * + * @param filteredContext The filtered notebook context (from `filterNotebookContext`) + * @param allCells All cells in the notebook + * @returns Array of cells to include in the context + */ +export function getCellsToInclude( + filteredContext: positron.notebooks.NotebookContext, + allCells: positron.notebooks.NotebookCell[] +): positron.notebooks.NotebookCell[] { + // If filtered context has allCells, use those + if (filteredContext.allCells && filteredContext.allCells.length > 0) { + return filteredContext.allCells; + } + + // For large notebooks without selection, filterNotebookContext sets allCells to undefined + // In that case, use a sliding window around recent executed cells + if (allCells.length >= MAX_CELLS_FOR_ALL_CELLS_CONTEXT && filteredContext.selectedCells.length === 0) { + const codeCells = allCells.filter(c => c.type === positron.notebooks.NotebookCellType.Code); + const executedCells = codeCells.filter(c => c.executionOrder !== undefined); + + if (executedCells.length > 0) { + const lastExecutedIndex = Math.max(...executedCells.map(c => c.index)); + const { startIndex, endIndex } = calculateSlidingWindow(allCells.length, lastExecutedIndex); + return allCells.slice(startIndex, endIndex); + } else { + // No executed cells, use first 20 cells + return allCells.slice(0, MAX_CELLS_FOR_ALL_CELLS_CONTEXT); + } + } + + // Fallback: use all cells (shouldn't happen, but safe fallback) + return allCells; +} diff --git a/extensions/positron-assistant/src/notebookSuggestions.ts b/extensions/positron-assistant/src/notebookSuggestions.ts index a71a21fec02c..635cbaf15219 100644 --- a/extensions/positron-assistant/src/notebookSuggestions.ts +++ b/extensions/positron-assistant/src/notebookSuggestions.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import { ParticipantService } from './participants.js'; import { MARKDOWN_DIR } from './constants'; +import { serializeNotebookContext } from './tools/notebookUtils.js'; /** * Interface for notebook action suggestions returned to the workbench @@ -36,11 +37,8 @@ export async function generateNotebookSuggestions( log: vscode.LogOutputChannel, token: vscode.CancellationToken ): Promise { - log.info(`[notebook-suggestions] Generating suggestions for notebook: ${notebookUri}`); - // Get the model to use for generation const model = await getModel(participantService); - log.info(`[notebook-suggestions] Using model (${model.vendor}) ${model.id}`); // Get notebook context const context = await positron.notebooks.getContext(); @@ -52,9 +50,14 @@ export async function generateNotebookSuggestions( // Get all cells if not already included in context const allCells = context.allCells || await positron.notebooks.getCells(notebookUri); - // Build context summary for the prompt - const contextSummary = buildContextSummary(context, allCells); - log.trace(`[notebook-suggestions] Context summary:\n${contextSummary}`); + // Ensure context has allCells populated for serialization + const contextWithAllCells = { + ...context, + allCells + }; + + // Build context summary using unified serialization helper + const contextSummary = buildContextSummary(contextWithAllCells); // Load the system prompt template const systemPrompt = await fs.promises.readFile( @@ -63,30 +66,30 @@ export async function generateNotebookSuggestions( ); try { + // Construct messages for the request + const systemMessage = new vscode.LanguageModelChatMessage( + vscode.LanguageModelChatMessageRole.System, + systemPrompt + ); + const userMessage = vscode.LanguageModelChatMessage.User(contextSummary); + // Send request to LLM const response = await model.sendRequest([ - new vscode.LanguageModelChatMessage( - vscode.LanguageModelChatMessageRole.System, - systemPrompt - ), - vscode.LanguageModelChatMessage.User(contextSummary) + systemMessage, + userMessage ], {}, token); // Accumulate the response let jsonResponse = ''; for await (const delta of response.text) { if (token.isCancellationRequested) { - log.info('[notebook-suggestions] Generation cancelled by user'); return []; } jsonResponse += delta; } - log.trace(`[notebook-suggestions] Raw LLM response:\n${jsonResponse}`); - // Parse and validate the JSON response const suggestions = parseAndValidateSuggestions(jsonResponse, log); - log.info(`[notebook-suggestions] Generated ${suggestions.length} suggestions`); return suggestions; @@ -103,68 +106,21 @@ export async function generateNotebookSuggestions( } /** - * Build a context summary string from notebook context + * Build a context summary string from notebook context using XML format (matching chat mode) + * + * This function uses the unified serialization helper which handles filtering internally. + * Filtering rules: + * - Small notebooks (< 20 cells): All cells + * - Large notebooks (>= 20 cells) with selection: Sliding window around selected cells + * - Large notebooks (>= 20 cells) without selection: Sliding window around recent executed cells */ function buildContextSummary( - context: positron.notebooks.NotebookContext, - allCells: positron.notebooks.NotebookCell[] + context: positron.notebooks.NotebookContext ): string { - const parts: string[] = []; - - // Basic info - parts.push(`Notebook: ${context.uri}`); - parts.push(`Kernel Language: ${context.kernelLanguage || 'unknown'}`); - parts.push(`Total Cells: ${context.cellCount}`); - parts.push(`Selected Cells: ${context.selectedCells.length}`); - - // Cell type breakdown - const codeCells = allCells.filter(c => c.type === positron.notebooks.NotebookCellType.Code); - const markdownCells = allCells.filter(c => c.type === positron.notebooks.NotebookCellType.Markdown); - parts.push(`Code Cells: ${codeCells.length}`); - parts.push(`Markdown Cells: ${markdownCells.length}`); - - // Execution status - const executedCells = codeCells.filter(c => c.executionOrder !== undefined); - const failedCells = codeCells.filter(c => c.lastRunSuccess === false); - const cellsWithOutput = allCells.filter(c => c.hasOutput); - parts.push(`Executed Cells: ${executedCells.length}`); - parts.push(`Failed Cells: ${failedCells.length}`); - parts.push(`Cells with Output: ${cellsWithOutput.length}`); - - // Selected cell content (if any) - if (context.selectedCells.length > 0) { - parts.push('\n## Selected Cells:'); - context.selectedCells.forEach(cell => { - parts.push(`\n### Cell ${cell.index} (${cell.type})`); - if (cell.type === positron.notebooks.NotebookCellType.Code) { - parts.push(`Status: ${cell.executionStatus || 'not executed'}`); - if (cell.lastRunSuccess !== undefined) { - parts.push(`Last Run: ${cell.lastRunSuccess ? 'success' : 'failed'}`); - } - } - // Include a snippet of the content (first 200 characters) - const contentSnippet = cell.content.substring(0, 200); - parts.push(`Content: ${contentSnippet}${cell.content.length > 200 ? '...' : ''}`); - }); - } - - // Recent cells (last 3 executed cells if no selection) - if (context.selectedCells.length === 0 && executedCells.length > 0) { - const recentCells = executedCells - .sort((a, b) => (b.executionOrder || 0) - (a.executionOrder || 0)) - .slice(0, 3); - - parts.push('\n## Recently Executed Cells:'); - recentCells.forEach(cell => { - parts.push(`\n### Cell ${cell.index}`); - parts.push(`Status: ${cell.executionStatus || 'completed'}`); - parts.push(`Success: ${cell.lastRunSuccess ? 'yes' : 'no'}`); - const contentSnippet = cell.content.substring(0, 150); - parts.push(`Content: ${contentSnippet}${cell.content.length > 150 ? '...' : ''}`); - }); - } + const serialized = serializeNotebookContext(context, { wrapInNotebookContext: true }); - return parts.join('\n'); + // Return the full wrapped context (guaranteed to be present when wrapInNotebookContext is true) + return serialized.fullContext || ''; } /** @@ -198,7 +154,6 @@ function parseAndValidateSuggestions( } catch (error) { log.error(`[notebook-suggestions] Failed to parse LLM response as JSON: ${error}`); - log.trace(`[notebook-suggestions] Attempted to parse: ${jsonResponse}`); return []; } } diff --git a/extensions/positron-assistant/src/participants.ts b/extensions/positron-assistant/src/participants.ts index 0b4297bd3d99..ebd1f6b71640 100644 --- a/extensions/positron-assistant/src/participants.ts +++ b/extensions/positron-assistant/src/participants.ts @@ -21,8 +21,7 @@ import { getCommitChanges } from './git.js'; import { getEnabledTools, getPositronContextPrompts } from './api.js'; import { TokenUsage } from './tokens.js'; import { PromptRenderer } from './promptRender.js'; -import { formatCells } from './tools/notebookUtils.js'; -import { filterNotebookContext, calculateSlidingWindow } from './notebookContextFilter.js'; +import { formatCells, SerializedNotebookContext, serializeNotebookContext, getAttachedNotebookContext } from './tools/notebookUtils.js'; export enum ParticipantID { /** The participant used in the chat pane in Ask mode. */ @@ -752,64 +751,6 @@ abstract class PositronAssistantParticipant implements IPositronAssistantPartici dispose(): void { } } -/** - * Checks if notebook mode should be enabled based on attached context. - * Returns filtered notebook context only if: - * 1. A notebook editor is currently active - * 2. That notebook's URI is attached as context - * - * Applies filtering to limit context size for large notebooks. - */ -export async function getAttachedNotebookContext( - request: vscode.ChatRequest -): Promise { - // Check if notebook mode feature is enabled - const notebookModeEnabled = vscode.workspace - .getConfiguration('positron.assistant.notebookMode') - .get('enable', false); - - if (!notebookModeEnabled) { - return undefined; - } - - // Get active editor's notebook context (unfiltered from main thread) - const activeContext = await positron.notebooks.getContext(); - if (!activeContext) { - return undefined; - } - - // Extract attached notebook URIs - const attachedNotebookUris = request.references - .map(ref => { - // Check for activeSession.notebookUri - const sessionNotebookUri = (ref.value as any)?.activeSession?.notebookUri; - if (typeof sessionNotebookUri === 'string') { - return sessionNotebookUri; - } - // Check for direct .ipynb file reference - if (ref.value instanceof vscode.Uri && ref.value.path.endsWith('.ipynb')) { - return ref.value.toString(); - } - return undefined; - }) - .filter(uri => typeof uri === 'string'); - - if (attachedNotebookUris.length === 0) { - return undefined; - } - - // Check if active notebook is in attached context - const isActiveNotebookAttached = attachedNotebookUris.includes( - activeContext.uri - ); - - if (!isActiveNotebookAttached) { - return undefined; - } - - // Apply filtering before returning context - return filterNotebookContext(activeContext); -} /** The participant used in the chat pane in Ask mode. */ export class PositronAssistantChatParticipant extends PositronAssistantParticipant implements IPositronAssistantParticipant { @@ -820,7 +761,7 @@ export class PositronAssistantChatParticipant extends PositronAssistantParticipa const sessions = activeSessions.map(session => session.runtimeMetadata); // Get notebook context if available, with error handling - let notebookContext: positron.notebooks.NotebookContext | undefined; + let notebookContext: SerializedNotebookContext | undefined; try { notebookContext = await getAttachedNotebookContext(request); } catch (err) { @@ -1012,17 +953,23 @@ export class PositronAssistantNotebookParticipant extends PositronAssistantEdito return super.getCustomPrompt(request); } - // Calculate adaptive 20-cell context window around the current cell - // Window rules: - // - Cell in middle: 10 cells before + current + 10 cells after - // - Cell at top: current + 20 cells after - // - Cell at bottom: 20 cells before + current - // - Notebook < 21 cells: all cells included const currentIndex = currentCell.index; const totalCells = allCells.length; - const { startIndex, endIndex } = calculateSlidingWindow(totalCells, currentIndex); - const contextCells = allCells.slice(startIndex, endIndex); + // Ensure context has allCells populated for serialization + const contextWithAllCells = { + ...notebookContext, + allCells + }; + + // Use unified serialization helper with current cell as anchor + // This applies filtering logic (sliding window around current cell) + const serialized = serializeNotebookContext(contextWithAllCells, { + anchorIndex: currentIndex + }); + + // Get filtered context cells (helper applies sliding window internally) + const contextCells = serialized.cellsToInclude || []; // Format current cell separately to highlight it const currentCellText = formatCells({ cells: [currentCell], prefix: 'Current Cell' }); @@ -1030,6 +977,10 @@ export class PositronAssistantNotebookParticipant extends PositronAssistantEdito // Format context cells (including current cell in the window) const contextCellsText = formatCells({ cells: contextCells, prefix: 'Cell' }); + // Calculate window bounds for description (helper uses sliding window internally) + const startIndex = contextCells.length > 0 ? Math.min(...contextCells.map(c => c.index)) : currentIndex; + const endIndex = contextCells.length > 0 ? Math.max(...contextCells.map(c => c.index)) + 1 : currentIndex + 1; + // Get file path for context const notebookPath = uriToString(vscode.Uri.parse(notebookContext.uri)); diff --git a/extensions/positron-assistant/src/promptRender.ts b/extensions/positron-assistant/src/promptRender.ts index defe821b6d55..cb6269dda521 100644 --- a/extensions/positron-assistant/src/promptRender.ts +++ b/extensions/positron-assistant/src/promptRender.ts @@ -10,7 +10,7 @@ import * as positron from 'positron'; import * as yaml from 'yaml'; import { MARKDOWN_DIR } from './constants'; import { log } from './extension.js'; -import { formatCells } from './tools/notebookUtils.js'; +import { SerializedNotebookContext, serializeNotebookContext } from './tools/notebookUtils.js'; import * as xml from './xml.js'; const PROMPT_MODE_SELECTIONS_KEY = 'positron.assistant.promptModeSelections'; @@ -51,40 +51,10 @@ class PromptTemplateEngine { let notebookContextNote: string | undefined; if (data.notebookContext) { - const ctx = data.notebookContext; - - // Format kernel information as XML - notebookKernelInfo = ctx.kernelId - ? xml.node('kernel', '', { - language: ctx.kernelLanguage || 'unknown', - id: ctx.kernelId - }) - : xml.node('kernel', 'No kernel attached'); - - // Format selected cells (already XML from formatCells) - notebookSelectedCellsInfo = formatCells({ cells: ctx.selectedCells, prefix: 'Selected Cell' }); - - // Format all cells if available as XML - if (ctx.allCells && ctx.allCells.length > 0) { - const isFullNotebook = ctx.cellCount < 20; - const description = isFullNotebook - ? 'All cells in notebook (notebook has fewer than 20 cells)' - : 'Context window around selected cells (notebook has 20+ cells)'; - notebookAllCellsInfo = xml.node('all-cells', formatCells({ cells: ctx.allCells, prefix: 'Cell' }), { - description - }); - } - - // Context note as XML - if (ctx.allCells && ctx.allCells.length > 0) { - if (ctx.cellCount < 20) { - notebookContextNote = xml.node('note', 'All cells are provided above because this notebook has fewer than 20 cells.'); - } else { - notebookContextNote = xml.node('note', 'A context window around the selected cells is provided above. Use the GetNotebookCells tool to retrieve additional cells by index when needed.'); - } - } else { - notebookContextNote = xml.node('note', 'Only selected cells are shown above to conserve tokens. Use the GetNotebookCells tool to retrieve additional cells by index when needed.'); - } + notebookKernelInfo = data.notebookContext.kernelInfo; + notebookSelectedCellsInfo = data.notebookContext.selectedCellsInfo; + notebookAllCellsInfo = data.notebookContext.allCellsInfo; + notebookContextNote = data.notebookContext.contextNote; } return { @@ -317,7 +287,7 @@ interface PromptRenderData { document?: vscode.TextDocument; sessions?: Array; streamingEdits?: boolean; - notebookContext?: positron.notebooks.NotebookContext; + notebookContext?: SerializedNotebookContext; } export class PromptRenderer { diff --git a/extensions/positron-assistant/src/tools/notebookUtils.ts b/extensions/positron-assistant/src/tools/notebookUtils.ts index 989320f38863..9f01cd28ab67 100644 --- a/extensions/positron-assistant/src/tools/notebookUtils.ts +++ b/extensions/positron-assistant/src/tools/notebookUtils.ts @@ -5,12 +5,21 @@ import * as vscode from 'vscode'; import * as positron from 'positron'; +import * as xml from '../xml.js'; +import { calculateSlidingWindow, filterNotebookContext } from '../notebookContextFilter.js'; /** * Maximum preview length per cell for confirmations (characters) */ const MAX_CELL_PREVIEW_LENGTH = 500; +/** + * Maximum number of cells in a notebook to include all cells in the context. + * Notebooks with more cells will have filtering applied to avoid consuming + * too much context space. + */ +const MAX_CELLS_FOR_ALL_CELLS_CONTEXT = 20; + /** * Maximum cell content length (1MB) */ @@ -219,3 +228,286 @@ export function convertOutputsToLanguageModelParts( return resultParts; } + +/** + * Options for serializing notebook context + */ +export interface NotebookContextSerializationOptions { + /** Optional anchor for sliding window. Defaults: last selected cell → last executed cell → 0 */ + anchorIndex?: number; + /** Default: false. If true, wraps everything in node (for suggestions format) */ + wrapInNotebookContext?: boolean; +} + +/** + * Serialized notebook context components + */ +export interface SerializedNotebookContext { + /** Kernel information XML node */ + kernelInfo: string; + /** Cell count information XML node (used internally in wrapped format) */ + cellCountInfo?: string; + /** Selected cells XML (may be empty if no selection) */ + selectedCellsInfo: string; + /** All cells XML (present if cells available after filtering) */ + allCellsInfo?: string; + /** Context note XML */ + contextNote: string; + /** Full wrapped context (if wrapInNotebookContext is true) */ + fullContext?: string; + /** Filtered cells that were included (for use cases that need the actual cells) */ + cellsToInclude?: positron.notebooks.NotebookCell[]; +} + +/** + * Serialize notebook context to XML format with integrated filtering logic. + * + * This function serves as the single source of truth for notebook context serialization + * across notebook suggestions, chat pane prompts, and inline chat. It handles filtering + * internally and generates consistent XML components. + * + * @param context The notebook context to serialize + * @param options Serialization options + * @returns Serialized notebook context components + */ +export function serializeNotebookContext( + context: positron.notebooks.NotebookContext, + options: NotebookContextSerializationOptions = {} +): SerializedNotebookContext { + const { anchorIndex, wrapInNotebookContext = false } = options; + + // Get all cells from context (may already be filtered) + const allCells = context.allCells || []; + const totalCells = context.cellCount; + + // Determine anchor index for sliding window if not provided + let effectiveAnchorIndex: number; + if (anchorIndex !== undefined) { + effectiveAnchorIndex = anchorIndex; + } else if (context.selectedCells.length > 0) { + // Use last selected cell index + effectiveAnchorIndex = Math.max(...context.selectedCells.map(cell => cell.index)); + } else { + // Try to find last executed cell + const codeCells = allCells.filter(c => c.type === positron.notebooks.NotebookCellType.Code); + const executedCells = codeCells.filter(c => c.executionOrder !== undefined); + if (executedCells.length > 0) { + effectiveAnchorIndex = Math.max(...executedCells.map(c => c.index)); + } else { + // Fallback to 0 + effectiveAnchorIndex = 0; + } + } + + // Apply filtering logic to determine which cells to include + let cellsToInclude: positron.notebooks.NotebookCell[]; + + if (totalCells < MAX_CELLS_FOR_ALL_CELLS_CONTEXT) { + // Small notebooks: include all cells + cellsToInclude = allCells.length > 0 ? allCells : []; + } else if (context.selectedCells.length === 0 && allCells.length === 0) { + // Large notebooks without selection and no allCells: no cells to include + cellsToInclude = []; + } else if (context.selectedCells.length === 0) { + // Large notebooks without selection: use sliding window around executed cells + const codeCells = allCells.filter(c => c.type === positron.notebooks.NotebookCellType.Code); + const executedCells = codeCells.filter(c => c.executionOrder !== undefined); + if (executedCells.length > 0 || effectiveAnchorIndex !== 0) { + const { startIndex, endIndex } = calculateSlidingWindow(allCells.length, effectiveAnchorIndex); + cellsToInclude = allCells.slice(startIndex, endIndex); + } else { + // No executed cells, use first 20 cells + cellsToInclude = allCells.slice(0, MAX_CELLS_FOR_ALL_CELLS_CONTEXT); + } + } else { + // Large notebooks with selection: use sliding window around anchor + if (allCells.length > 0) { + const { startIndex, endIndex } = calculateSlidingWindow(allCells.length, effectiveAnchorIndex); + cellsToInclude = allCells.slice(startIndex, endIndex); + } else { + cellsToInclude = []; + } + } + + // Generate kernel info XML (using xml.node for consistency) + const kernelInfo = context.kernelId + ? xml.node('kernel', '', { + language: context.kernelLanguage || 'unknown', + id: context.kernelId + }) + : xml.node('kernel', 'No kernel attached'); + + // Generate cell count info XML + const cellCountInfo = xml.node('cell-count', '', { + total: context.cellCount, + selected: context.selectedCells.length, + included: cellsToInclude.length + }); + + // Generate selected cells XML + const selectedCellsInfo = formatCells({ cells: context.selectedCells, prefix: 'Selected Cell' }); + + // Generate all cells XML if available + let allCellsInfo: string | undefined; + if (cellsToInclude.length > 0) { + const isFullNotebook = context.cellCount < 20; + const description = isFullNotebook + ? 'All cells in notebook (notebook has fewer than 20 cells)' + : 'Context window around selected/recent cells (notebook has 20+ cells)'; + allCellsInfo = xml.node('all-cells', formatCells({ cells: cellsToInclude, prefix: 'Cell' }), { + description + }); + } + + // Generate context note XML + let contextNote: string; + if (cellsToInclude.length > 0) { + if (context.cellCount < 20) { + contextNote = xml.node('note', 'All cells are provided above because this notebook has fewer than 20 cells.'); + } else { + contextNote = xml.node('note', 'A context window around the selected/recent cells is provided above. Use the GetNotebookCells tool to retrieve additional cells by index when needed.'); + } + } else { + contextNote = xml.node('note', 'Only selected cells are shown above to conserve tokens. Use the GetNotebookCells tool to retrieve additional cells by index when needed.'); + } + + // Build result + const result: SerializedNotebookContext = { + kernelInfo, + cellCountInfo, + selectedCellsInfo, + allCellsInfo, + contextNote, + cellsToInclude + }; + + // Optionally wrap in notebook-context node + if (wrapInNotebookContext) { + const isFullNotebook = context.cellCount < 20; + const contextMode = isFullNotebook + ? 'Full notebook (< 20 cells, all cells provided below)' + : 'Context window around selected/recent cells (notebook has 20+ cells)'; + + const contextModeNode = xml.node('context-mode', contextMode); + const notebookInfo = xml.node('notebook-info', `${kernelInfo}\n${cellCountInfo}`); + + const parts: string[] = [xml.node('notebook-context', `${notebookInfo}\n${contextModeNode}`)]; + + if (context.selectedCells.length > 0) { + parts.push(xml.node('selected-cells', selectedCellsInfo)); + } + + if (allCellsInfo) { + parts.push(allCellsInfo); + } + + parts.push(contextNote); + + result.fullContext = parts.join('\n\n'); + } + + return result; +} + +/** + * Checks if there is an attached notebook context without applying filtering or serialization. + * Returns the raw notebook context if: + * 1. Notebook mode feature is enabled + * 2. A notebook editor is currently active + * 3. That notebook's URI is attached as context + * + * This is useful for tool availability checks that don't need the full filtered/serialized context. + * + * @param request The chat request to check for attached notebook context + * @returns The raw notebook context if attached, undefined otherwise + */ +async function getRawAttachedNotebookContext( + request: vscode.ChatRequest +): Promise { + // Check if notebook mode feature is enabled + const notebookModeEnabled = vscode.workspace + .getConfiguration('positron.assistant.notebookMode') + .get('enable', false); + + if (!notebookModeEnabled) { + return undefined; + } + + // Get active editor's notebook context (unfiltered from main thread) + const activeContext = await positron.notebooks.getContext(); + if (!activeContext) { + return undefined; + } + + // Extract attached notebook URIs + const attachedNotebookUris = request.references + .map(ref => { + // Check for activeSession.notebookUri + const sessionNotebookUri = (ref.value as any)?.activeSession?.notebookUri; + if (typeof sessionNotebookUri === 'string') { + return sessionNotebookUri; + } + // Check for direct .ipynb file reference + if (ref.value instanceof vscode.Uri && ref.value.path.endsWith('.ipynb')) { + return ref.value.toString(); + } + return undefined; + }) + .filter(uri => typeof uri === 'string'); + + if (attachedNotebookUris.length === 0) { + return undefined; + } + + // Check if active notebook is in attached context + const isActiveNotebookAttached = attachedNotebookUris.includes( + activeContext.uri + ); + + if (!isActiveNotebookAttached) { + return undefined; + } + + return activeContext; +} + +/** + * Checks if there is an attached notebook context. + * Returns true if: + * 1. Notebook mode feature is enabled + * 2. A notebook editor is currently active + * 3. That notebook's URI is attached as context + * + * This is a lightweight check for tool availability that doesn't require + * filtering or serialization of the notebook context. + * + * @param request The chat request to check for attached notebook context + * @returns True if there is an attached notebook context, false otherwise + */ +export async function hasAttachedNotebookContext( + request: vscode.ChatRequest +): Promise { + const context = await getRawAttachedNotebookContext(request); + return context !== undefined; +} + +/** + * Checks if notebook mode should be enabled based on attached context. + * Returns filtered notebook context only if: + * 1. A notebook editor is currently active + * 2. That notebook's URI is attached as context + * + * Applies filtering to limit context size for large notebooks. + */ +export async function getAttachedNotebookContext( + request: vscode.ChatRequest +): Promise { + const activeContext = await getRawAttachedNotebookContext(request); + if (!activeContext) { + return undefined; + } + + // Apply filtering before returning context + const filteredContext = filterNotebookContext(activeContext); + return serializeNotebookContext(filteredContext); +} From 9ef9e45d600e265f21b728cf00b5f7d77cd1f112 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 14:17:42 -0500 Subject: [PATCH 15/25] Fix disposable leak in the ask assistant action quickpick logic --- .../browser/AskAssistantAction.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts index 27b0374a66ff..bfe55c1d38c6 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts @@ -14,6 +14,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { isCancellationError } from '../../../../base/common/errors.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { CHAT_OPEN_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; import { ChatModeKind } from '../../chat/common/constants.js'; import { POSITRON_NOTEBOOK_EDITOR_ID } from '../common/positronNotebookCommon.js'; @@ -123,8 +124,11 @@ export class AskAssistantAction extends NotebookAction2 { quickPick.items = ASSISTANT_PREDEFINED_ACTIONS; quickPick.canSelectMany = false; + // Create a disposable store to track event listener disposables + const disposables = new DisposableStore(); + // Handle accept with veto pattern for AI generation and custom prompt validation - quickPick.onWillAccept((e) => { + disposables.add(quickPick.onWillAccept((e) => { const selected = quickPick.selectedItems[0]; @@ -165,11 +169,11 @@ export class AskAssistantAction extends NotebookAction2 { ) ); } - }); + })); // Wait for user selection or custom input const result = await new Promise((resolve) => { - quickPick.onDidAccept(() => { + disposables.add(quickPick.onDidAccept(() => { // Check if a predefined item was selected const selected = quickPick.selectedItems[0]; const customValue = quickPick.value.trim(); @@ -190,18 +194,20 @@ export class AskAssistantAction extends NotebookAction2 { // No selection and no input resolve(undefined); } + disposables.dispose(); quickPick.dispose(); - }); + })); quickPick.show(); - quickPick.onDidHide(() => { + disposables.add(quickPick.onDidHide(() => { // Cancel any ongoing AI generation when the quick pick is hidden cancellationTokenSource.cancel(); cancellationTokenSource.dispose(); + disposables.dispose(); quickPick.dispose(); resolve(undefined); - }); + })); }); cancellationTokenSource.dispose(); From 07b99580ec5728d1d3d13a7cfed4f3dc48076e66 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 14:26:14 -0500 Subject: [PATCH 16/25] Inline small function --- .../src/notebookSuggestions.ts | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/extensions/positron-assistant/src/notebookSuggestions.ts b/extensions/positron-assistant/src/notebookSuggestions.ts index 635cbaf15219..c96f2b607baa 100644 --- a/extensions/positron-assistant/src/notebookSuggestions.ts +++ b/extensions/positron-assistant/src/notebookSuggestions.ts @@ -56,8 +56,9 @@ export async function generateNotebookSuggestions( allCells }; - // Build context summary using unified serialization helper - const contextSummary = buildContextSummary(contextWithAllCells); + // Build serialized context + const serialized = serializeNotebookContext(contextWithAllCells, { wrapInNotebookContext: true }); + const contextSummary = serialized.fullContext || ''; // Load the system prompt template const systemPrompt = await fs.promises.readFile( @@ -105,24 +106,6 @@ export async function generateNotebookSuggestions( } } -/** - * Build a context summary string from notebook context using XML format (matching chat mode) - * - * This function uses the unified serialization helper which handles filtering internally. - * Filtering rules: - * - Small notebooks (< 20 cells): All cells - * - Large notebooks (>= 20 cells) with selection: Sliding window around selected cells - * - Large notebooks (>= 20 cells) without selection: Sliding window around recent executed cells - */ -function buildContextSummary( - context: positron.notebooks.NotebookContext -): string { - const serialized = serializeNotebookContext(context, { wrapInNotebookContext: true }); - - // Return the full wrapped context (guaranteed to be present when wrapInNotebookContext is true) - return serialized.fullContext || ''; -} - /** * Parse and validate the LLM response as JSON suggestions */ From 19af4afde544ac6ad9e44d9f41d3d1f1bd8a5987 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 14:48:13 -0500 Subject: [PATCH 17/25] Switch to same serialization protocol for inline chat as other notebook contexts --- .../positron-assistant/src/participants.ts | 51 +++---------------- .../src/tools/notebookUtils.ts | 10 ++-- 2 files changed, 12 insertions(+), 49 deletions(-) diff --git a/extensions/positron-assistant/src/participants.ts b/extensions/positron-assistant/src/participants.ts index ebd1f6b71640..8466cc4bb946 100644 --- a/extensions/positron-assistant/src/participants.ts +++ b/extensions/positron-assistant/src/participants.ts @@ -21,7 +21,7 @@ import { getCommitChanges } from './git.js'; import { getEnabledTools, getPositronContextPrompts } from './api.js'; import { TokenUsage } from './tokens.js'; import { PromptRenderer } from './promptRender.js'; -import { formatCells, SerializedNotebookContext, serializeNotebookContext, getAttachedNotebookContext } from './tools/notebookUtils.js'; +import { SerializedNotebookContext, serializeNotebookContext, getAttachedNotebookContext } from './tools/notebookUtils.js'; export enum ParticipantID { /** The participant used in the chat pane in Ask mode. */ @@ -954,7 +954,6 @@ export class PositronAssistantNotebookParticipant extends PositronAssistantEdito } const currentIndex = currentCell.index; - const totalCells = allCells.length; // Ensure context has allCells populated for serialization const contextWithAllCells = { @@ -962,51 +961,15 @@ export class PositronAssistantNotebookParticipant extends PositronAssistantEdito allCells }; - // Use unified serialization helper with current cell as anchor - // This applies filtering logic (sliding window around current cell) + // Use unified serialization helper with current cell as anchor and full wrapping + // This applies filtering logic (sliding window around current cell) and formats consistently with chat pane const serialized = serializeNotebookContext(contextWithAllCells, { - anchorIndex: currentIndex + anchorIndex: currentIndex, + wrapInNotebookContext: true }); - // Get filtered context cells (helper applies sliding window internally) - const contextCells = serialized.cellsToInclude || []; - - // Format current cell separately to highlight it - const currentCellText = formatCells({ cells: [currentCell], prefix: 'Current Cell' }); - - // Format context cells (including current cell in the window) - const contextCellsText = formatCells({ cells: contextCells, prefix: 'Cell' }); - - // Calculate window bounds for description (helper uses sliding window internally) - const startIndex = contextCells.length > 0 ? Math.min(...contextCells.map(c => c.index)) : currentIndex; - const endIndex = contextCells.length > 0 ? Math.max(...contextCells.map(c => c.index)) + 1 : currentIndex + 1; - - // Get file path for context - const notebookPath = uriToString(vscode.Uri.parse(notebookContext.uri)); - - // Build the notebook context node - const notebookNodes = [ - xml.node('current-cell', currentCellText, { - description: 'The cell where inline chat was triggered', - index: currentCell.index, - type: currentCell.type, - }), - xml.node('context-cells', contextCellsText, { - description: `Context window: cells ${startIndex} to ${endIndex - 1} of ${totalCells - 1}`, - windowSize: contextCells.length, - totalCells: totalCells, - }), - ]; - - const notebookNode = xml.node('notebook', notebookNodes.join('\n'), { - description: 'Notebook context for inline chat', - notebookPath, - kernelLanguage: notebookContext.kernelLanguage || 'unknown', - currentCellIndex: currentIndex, - }); - - log.debug(`[notebook participant] Adding notebook context: ${notebookNode.length} characters, ${contextCells.length} cells in window`); - return notebookNode; + const serializedContext = serialized.fullContext || ''; + return serializedContext; } } diff --git a/extensions/positron-assistant/src/tools/notebookUtils.ts b/extensions/positron-assistant/src/tools/notebookUtils.ts index 9f01cd28ab67..032a87f711a7 100644 --- a/extensions/positron-assistant/src/tools/notebookUtils.ts +++ b/extensions/positron-assistant/src/tools/notebookUtils.ts @@ -255,8 +255,6 @@ export interface SerializedNotebookContext { contextNote: string; /** Full wrapped context (if wrapInNotebookContext is true) */ fullContext?: string; - /** Filtered cells that were included (for use cases that need the actual cells) */ - cellsToInclude?: positron.notebooks.NotebookCell[]; } /** @@ -349,12 +347,15 @@ export function serializeNotebookContext( // Generate all cells XML if available let allCellsInfo: string | undefined; + let formattedCells: string | undefined; if (cellsToInclude.length > 0) { const isFullNotebook = context.cellCount < 20; const description = isFullNotebook ? 'All cells in notebook (notebook has fewer than 20 cells)' : 'Context window around selected/recent cells (notebook has 20+ cells)'; - allCellsInfo = xml.node('all-cells', formatCells({ cells: cellsToInclude, prefix: 'Cell' }), { + // Format cells once and reuse + formattedCells = formatCells({ cells: cellsToInclude, prefix: 'Cell' }); + allCellsInfo = xml.node('all-cells', formattedCells, { description }); } @@ -377,8 +378,7 @@ export function serializeNotebookContext( cellCountInfo, selectedCellsInfo, allCellsInfo, - contextNote, - cellsToInclude + contextNote }; // Optionally wrap in notebook-context node From 0485a6042c117ef82f5a520a74762de5c69549fc Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Thu, 13 Nov 2025 16:33:13 -0500 Subject: [PATCH 18/25] More type safety updates --- .../src/notebookSuggestions.ts | 55 +++++++++++++++---- .../positron-assistant/src/participants.ts | 8 +-- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/extensions/positron-assistant/src/notebookSuggestions.ts b/extensions/positron-assistant/src/notebookSuggestions.ts index c96f2b607baa..d812a23fe04f 100644 --- a/extensions/positron-assistant/src/notebookSuggestions.ts +++ b/extensions/positron-assistant/src/notebookSuggestions.ts @@ -23,6 +23,25 @@ export interface NotebookActionSuggestion { iconClass?: string; } +/** + * Raw suggestion object structure as parsed from JSON + * Used for type-safe validation of LLM responses + */ +interface RawSuggestion { + label?: unknown; + detail?: unknown; + query?: unknown; + mode?: unknown; + iconClass?: unknown; +} + +/** + * Type guard to check if a value is a record-like object + */ +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + /** * Generate AI-powered action suggestions based on notebook context * @param notebookUri URI of the notebook to analyze @@ -123,15 +142,16 @@ function parseAndValidateSuggestions( jsonString = codeBlockMatch[1].trim(); } - // Parse the JSON - const parsed = JSON.parse(jsonString); + // Parse the JSON - result is unknown, not any + const parsed: unknown = JSON.parse(jsonString); - // Ensure it's an array - const suggestions = Array.isArray(parsed) ? parsed : [parsed]; + // Ensure it's an array of unknown values + const suggestions: unknown[] = Array.isArray(parsed) ? parsed : [parsed]; // Validate and normalize each suggestion + // Type guard narrows unknown to RawSuggestion, then normalize converts to NotebookActionSuggestion return suggestions - .filter(s => validateSuggestion(s, log)) + .filter((s): s is RawSuggestion => validateSuggestion(s, log)) .map(s => normalizeSuggestion(s)) .slice(0, 5); // Limit to 5 suggestions @@ -142,10 +162,13 @@ function parseAndValidateSuggestions( } /** - * Validate that a suggestion object has required fields + * Type guard to validate that a suggestion object has required fields + * @param suggestion The unknown value to validate + * @param log Log output channel for debugging + * @returns True if the suggestion is a valid RawSuggestion */ -function validateSuggestion(suggestion: any, log: vscode.LogOutputChannel): boolean { - if (!suggestion || typeof suggestion !== 'object') { +function validateSuggestion(suggestion: unknown, log: vscode.LogOutputChannel): suggestion is RawSuggestion { + if (!isRecord(suggestion)) { log.warn('[notebook-suggestions] Invalid suggestion: not an object'); return false; } @@ -164,21 +187,29 @@ function validateSuggestion(suggestion: any, log: vscode.LogOutputChannel): bool } /** - * Normalize a suggestion object to match the expected interface + * Normalize a validated suggestion object to match the expected interface + * @param suggestion The validated raw suggestion from JSON parsing + * @returns Normalized NotebookActionSuggestion */ -function normalizeSuggestion(suggestion: any): NotebookActionSuggestion { +function normalizeSuggestion(suggestion: RawSuggestion): NotebookActionSuggestion { // Normalize mode to valid values let mode: 'ask' | 'edit' | 'agent' = 'agent'; if (suggestion.mode === 'ask' || suggestion.mode === 'edit' || suggestion.mode === 'agent') { mode = suggestion.mode; } + // validateSuggestion ensures label and query are strings, so these are safe to use + // We still check at runtime for extra safety + if (typeof suggestion.label !== 'string' || typeof suggestion.query !== 'string') { + throw new Error('Invalid suggestion: label and query must be strings'); + } + return { label: suggestion.label, - detail: suggestion.detail || undefined, + detail: typeof suggestion.detail === 'string' ? suggestion.detail : undefined, query: suggestion.query, mode, - iconClass: suggestion.iconClass || undefined + iconClass: typeof suggestion.iconClass === 'string' ? suggestion.iconClass : undefined }; } diff --git a/extensions/positron-assistant/src/participants.ts b/extensions/positron-assistant/src/participants.ts index 8466cc4bb946..adbb39352734 100644 --- a/extensions/positron-assistant/src/participants.ts +++ b/extensions/positron-assistant/src/participants.ts @@ -955,15 +955,9 @@ export class PositronAssistantNotebookParticipant extends PositronAssistantEdito const currentIndex = currentCell.index; - // Ensure context has allCells populated for serialization - const contextWithAllCells = { - ...notebookContext, - allCells - }; - // Use unified serialization helper with current cell as anchor and full wrapping // This applies filtering logic (sliding window around current cell) and formats consistently with chat pane - const serialized = serializeNotebookContext(contextWithAllCells, { + const serialized = serializeNotebookContext(notebookContext, { anchorIndex: currentIndex, wrapInNotebookContext: true }); From 9d458dfa0dc576a38477a13e89bb6cf60da3dcf2 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Fri, 14 Nov 2025 09:56:34 -0500 Subject: [PATCH 19/25] Dont duplicate the MAX_CELLS_FOR_ALL_CELLS_CONTEXT constant --- .../positron-assistant/src/notebookContextFilter.ts | 2 +- extensions/positron-assistant/src/tools/notebookUtils.ts | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/extensions/positron-assistant/src/notebookContextFilter.ts b/extensions/positron-assistant/src/notebookContextFilter.ts index 08f2657532a7..f128752193db 100644 --- a/extensions/positron-assistant/src/notebookContextFilter.ts +++ b/extensions/positron-assistant/src/notebookContextFilter.ts @@ -10,7 +10,7 @@ import * as positron from 'positron'; * Notebooks with more cells will have filtering applied to avoid consuming * too much context space. */ -const MAX_CELLS_FOR_ALL_CELLS_CONTEXT = 20; +export const MAX_CELLS_FOR_ALL_CELLS_CONTEXT = 20; /** * Default window size for sliding window filtering. diff --git a/extensions/positron-assistant/src/tools/notebookUtils.ts b/extensions/positron-assistant/src/tools/notebookUtils.ts index 032a87f711a7..51f1de19099f 100644 --- a/extensions/positron-assistant/src/tools/notebookUtils.ts +++ b/extensions/positron-assistant/src/tools/notebookUtils.ts @@ -6,20 +6,13 @@ import * as vscode from 'vscode'; import * as positron from 'positron'; import * as xml from '../xml.js'; -import { calculateSlidingWindow, filterNotebookContext } from '../notebookContextFilter.js'; +import { calculateSlidingWindow, filterNotebookContext, MAX_CELLS_FOR_ALL_CELLS_CONTEXT } from '../notebookContextFilter.js'; /** * Maximum preview length per cell for confirmations (characters) */ const MAX_CELL_PREVIEW_LENGTH = 500; -/** - * Maximum number of cells in a notebook to include all cells in the context. - * Notebooks with more cells will have filtering applied to avoid consuming - * too much context space. - */ -const MAX_CELLS_FOR_ALL_CELLS_CONTEXT = 20; - /** * Maximum cell content length (1MB) */ From 0129be8bc0c85f51a32b80914b49d8350f6e5171 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Fri, 14 Nov 2025 10:03:47 -0500 Subject: [PATCH 20/25] Properly cleanup CancellationToken --- extensions/positron-assistant/src/extension.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/extensions/positron-assistant/src/extension.ts b/extensions/positron-assistant/src/extension.ts index 717683a1d275..8dba97e700d4 100644 --- a/extensions/positron-assistant/src/extension.ts +++ b/extensions/positron-assistant/src/extension.ts @@ -239,8 +239,17 @@ function registerGenerateNotebookSuggestionsCommand( vscode.commands.registerCommand( 'positron-assistant.generateNotebookSuggestions', async (notebookUri: string, token?: vscode.CancellationToken) => { - const cancellationToken = token || new vscode.CancellationTokenSource().token; - return await generateNotebookSuggestions(notebookUri, participantService, log, cancellationToken); + // Create a token source only if no token is provided + let tokenSource: vscode.CancellationTokenSource | undefined; + // If there is no provided token, create a new one and also + // assign it to the tokenSource so we know to dispose it later. + const cancellationToken = token || (tokenSource = new vscode.CancellationTokenSource()).token; + try { + return await generateNotebookSuggestions(notebookUri, participantService, log, cancellationToken); + } finally { + // We only want to dispose the token if we created it + tokenSource?.dispose(); + } } ) ); From 14687783aba3826977723626ff1f152f1e164a12 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Fri, 14 Nov 2025 10:04:30 -0500 Subject: [PATCH 21/25] Remove dead function --- .../src/notebookContextFilter.ts | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/extensions/positron-assistant/src/notebookContextFilter.ts b/extensions/positron-assistant/src/notebookContextFilter.ts index f128752193db..da1c698e9776 100644 --- a/extensions/positron-assistant/src/notebookContextFilter.ts +++ b/extensions/positron-assistant/src/notebookContextFilter.ts @@ -81,42 +81,3 @@ export function filterNotebookContext( }; } -/** - * Determines which cells to include in context, handling fallback cases. - * - * This function extends `filterNotebookContext` by providing a fallback strategy - * for cases where `allCells` is undefined (large notebooks without selection). - * In such cases, it uses a sliding window around recent executed cells. - * - * @param filteredContext The filtered notebook context (from `filterNotebookContext`) - * @param allCells All cells in the notebook - * @returns Array of cells to include in the context - */ -export function getCellsToInclude( - filteredContext: positron.notebooks.NotebookContext, - allCells: positron.notebooks.NotebookCell[] -): positron.notebooks.NotebookCell[] { - // If filtered context has allCells, use those - if (filteredContext.allCells && filteredContext.allCells.length > 0) { - return filteredContext.allCells; - } - - // For large notebooks without selection, filterNotebookContext sets allCells to undefined - // In that case, use a sliding window around recent executed cells - if (allCells.length >= MAX_CELLS_FOR_ALL_CELLS_CONTEXT && filteredContext.selectedCells.length === 0) { - const codeCells = allCells.filter(c => c.type === positron.notebooks.NotebookCellType.Code); - const executedCells = codeCells.filter(c => c.executionOrder !== undefined); - - if (executedCells.length > 0) { - const lastExecutedIndex = Math.max(...executedCells.map(c => c.index)); - const { startIndex, endIndex } = calculateSlidingWindow(allCells.length, lastExecutedIndex); - return allCells.slice(startIndex, endIndex); - } else { - // No executed cells, use first 20 cells - return allCells.slice(0, MAX_CELLS_FOR_ALL_CELLS_CONTEXT); - } - } - - // Fallback: use all cells (shouldn't happen, but safe fallback) - return allCells; -} From efecf6ec67a20596c31184b2601760c5378fdd39 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Fri, 14 Nov 2025 10:04:58 -0500 Subject: [PATCH 22/25] Fix spacing --- .../contrib/positronNotebook/browser/AskAssistantAction.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts index bfe55c1d38c6..c27056aeea5d 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts @@ -131,7 +131,6 @@ export class AskAssistantAction extends NotebookAction2 { disposables.add(quickPick.onWillAccept((e) => { const selected = quickPick.selectedItems[0]; - // Check if "Generate AI suggestions" was selected (type guard for PromptQuickPickItem) if (selected && 'generateSuggestions' in selected && selected.generateSuggestions) { e.veto(); // Prevent the quick pick from closing From 69bad6ced0717799e1a865deef90e2005eb8c33d Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Fri, 14 Nov 2025 10:06:03 -0500 Subject: [PATCH 23/25] No any, please! --- .../contrib/positronNotebook/browser/NotebookAction2.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/positronNotebook/browser/NotebookAction2.ts b/src/vs/workbench/contrib/positronNotebook/browser/NotebookAction2.ts index fab9fb9c34df..ab97840c84f9 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/NotebookAction2.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/NotebookAction2.ts @@ -14,7 +14,7 @@ import { getNotebookInstanceFromActiveEditorPane } from './notebookUtils.js'; * Automatically gets the active notebook instance and passes it to the runNotebookAction method. */ export abstract class NotebookAction2 extends Action2 { - override async run(accessor: ServicesAccessor, ...args: any[]): Promise { + override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { const editorService = accessor.get(IEditorService); const activeNotebook = getNotebookInstanceFromActiveEditorPane(editorService); if (!activeNotebook) { @@ -27,6 +27,6 @@ export abstract class NotebookAction2 extends Action2 { } } - protected abstract runNotebookAction(notebook: IPositronNotebookInstance, accessor: ServicesAccessor): Promise | void; + protected abstract runNotebookAction(notebook: IPositronNotebookInstance, accessor: ServicesAccessor): Promise | void; } From 6f2549a6b10f6fc6c6095c25109ff930360f8241 Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Fri, 14 Nov 2025 10:35:53 -0500 Subject: [PATCH 24/25] Update predesigned prompts --- .../browser/AskAssistantAction.ts | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts index c27056aeea5d..8283f368e187 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/AskAssistantAction.ts @@ -45,25 +45,32 @@ interface PromptQuickPickItem extends IQuickPickItem { */ const ASSISTANT_PREDEFINED_ACTIONS: PromptQuickPickItem[] = [ { - label: localize('positronNotebook.assistant.prompt.describe', 'Describe the notebook'), - detail: localize('positronNotebook.assistant.prompt.describe.detail', 'Get an overview of the notebook\'s contents and structure'), - query: 'Can you describe the open notebook for me?', + label: localize('positronNotebook.assistant.prompt.explain', 'Explain this notebook'), + detail: localize('positronNotebook.assistant.prompt.explain.detail', 'Summarize what this notebook does and how it works'), + query: 'Explain this notebook: 1) Summarize the overall purpose and what it accomplishes, 2) Describe the key steps or workflow, 3) Highlight important code sections or techniques used, 4) Note any assumptions or prerequisites', mode: ChatModeKind.Ask, iconClass: ThemeIcon.asClassName(Codicon.book) }, { - label: localize('positronNotebook.assistant.prompt.comments', 'Add inline comments'), - detail: localize('positronNotebook.assistant.prompt.comments.detail', 'Add explanatory comments to the selected cell(s)'), - query: 'Can you add inline comments to the selected cell(s)?', + label: localize('positronNotebook.assistant.prompt.fix', 'Fix errors and issues'), + detail: localize('positronNotebook.assistant.prompt.fix.detail', 'Debug problems in notebook and suggest improvements'), + query: 'Fix issues in the notebook: 1) Identify and resolve any errors or warnings, 2) Explain what was wrong and why it occurred, 3) Suggest code quality improvements if applicable, 4) Provide corrected code following best practices', mode: ChatModeKind.Edit, - iconClass: ThemeIcon.asClassName(Codicon.commentAdd) + iconClass: ThemeIcon.asClassName(Codicon.wrench) + }, + { + label: localize('positronNotebook.assistant.prompt.improve', 'Improve this notebook'), + detail: localize('positronNotebook.assistant.prompt.improve.detail', 'Add documentation, organize structure, and enhance readability'), + query: 'Improve this notebook: 1) Add markdown documentation explaining what the notebook does, 2) Add comments to complex code sections, 3) Organize cells into logical sections, 4) Remove redundant code or cells, 5) Suggest structural improvements for clarity', + mode: ChatModeKind.Edit, + iconClass: ThemeIcon.asClassName(Codicon.edit) }, { label: localize('positronNotebook.assistant.prompt.suggest', 'Suggest next steps'), - detail: localize('positronNotebook.assistant.prompt.suggest.detail', 'Get recommendations for what to do next with this notebook'), - query: 'Can you suggest next steps for this notebook?', - mode: ChatModeKind.Ask, - iconClass: ThemeIcon.asClassName(Codicon.lightbulb) + detail: localize('positronNotebook.assistant.prompt.suggest.detail', 'Get AI recommendations for what to do next'), + query: 'Analyze this notebook and suggest next steps: 1) Assess what\'s been accomplished so far, 2) Identify what\'s incomplete or missing (analysis, validation, documentation, error handling), 3) Recommend 3-4 specific next actions with reasoning, 4) Flag any potential issues or improvements', + mode: ChatModeKind.Agent, + iconClass: ThemeIcon.asClassName(Codicon.lightbulbAutofix) }, { label: localize('positronNotebook.assistant.prompt.generateSuggestions', 'Generate AI suggestions...'), From 87938c3b58d9beffa7e3ab5a379591c22b12548d Mon Sep 17 00:00:00 2001 From: Nick Strayer Date: Fri, 14 Nov 2025 12:58:09 -0500 Subject: [PATCH 25/25] Add configurable preference for faster models for suggested actions --- extensions/positron-assistant/package.json | 24 +++++++++ .../positron-assistant/package.nls.json | 1 + .../src/notebookSuggestions.ts | 51 ++++++++++++++++++- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/extensions/positron-assistant/package.json b/extensions/positron-assistant/package.json index 69c25bcf18e2..bf0b92336981 100644 --- a/extensions/positron-assistant/package.json +++ b/extensions/positron-assistant/package.json @@ -366,6 +366,30 @@ "experimental" ] }, + "positron.assistant.notebookSuggestions.model": { + "type": "array", + "default": [ + "haiku", + "mini" + ], + "markdownDescription": "%configuration.notebookSuggestions.model.description%", + "items": { + "type": "string" + }, + "examples": [ + [ + "haiku", + "mini" + ], + [ + "Claude Sonnet 4.5", + "GPT-5" + ] + ], + "tags": [ + "experimental" + ] + }, "positron.assistant.providerVariables.bedrock": { "type": "object", "default": {}, diff --git a/extensions/positron-assistant/package.nls.json b/extensions/positron-assistant/package.nls.json index 8c24cc8913ea..66970284cc2f 100644 --- a/extensions/positron-assistant/package.nls.json +++ b/extensions/positron-assistant/package.nls.json @@ -42,5 +42,6 @@ "configuration.filterModels.description": "A list of [glob patterns](https://aka.ms/vscode-glob-patterns) to filter which language models are available in Positron Assistant across all providers.\n\nOnly models with an ID or name matching at least one of the specified patterns will appear as available options. Models that do not match any pattern will be hidden.\n\nExamples:\n- `Claude*` shows only models starting with 'Claude'\n- `GPT-5` shows only models named 'GPT-5'\n- `**/google/gemini-*` shows only models with '/google/gemini-' in the ID\n- `**/*` shows all models", "configuration.preferredModel.description": "A preferred model ID or name (partial match supported) to use if available in Positron Assistant for the current provider.\n\nTakes precedence over `#positron.assistant.defaultModels#`.\n\nRequires a restart to take effect.\n\nExamples:\n- `Claude Sonnet 4.5` prefers the model named 'Claude Sonnet 4.5'\n- `GPT-5` prefers the model named 'GPT-5'", "configuration.defaultModels.description": "A mapping of provider IDs to default model IDs or names (partial match supported) to use for that provider in Positron Assistant.\n\n`#positron.assistant.preferredModel#` takes precedence over this setting.\n\nRequires a restart to take effect.\n\nExample: Item `anthropic-api` and Value `Claude Sonnet 4.5` sets the default model for Anthropic to 'Claude Sonnet 4.5'", + "configuration.notebookSuggestions.model.description": "An ordered array of model patterns to try when generating AI suggestions for Positron Notebooks. Patterns are tried in order until a match is found.\n\nEach pattern supports partial matching on model ID or name (case-insensitive). Default is `[\"haiku\", \"mini\"]` which tries to find a model with \"haiku\" in the name first, then tries \"mini\" if not found. If no patterns match or the array is empty, falls back to the current chat session model, then the current provider's model, then the first available model.\n\nExamples:\n- `[\"haiku\", \"mini\"]` (default) tries to find a model with \"haiku\" in the name first, then tries \"mini\" if not found\n- `[\"Claude Sonnet 4.5\", \"GPT-5\"]` tries \"Claude Sonnet 4.5\" first, then \"GPT-5\"\n- `[]` disables pattern matching and uses the default fallback behavior", "configuration.providerVariables.bedrock.description": "Variables used to configure advanced settings for Bedrock in Positron Assistant.\n\nRequires a restart to take effect.\n\nExample: to set the AWS region and profile for Amazon Bedrock, add items with keys `AWS_REGION` and `AWS_PROFILE`." } diff --git a/extensions/positron-assistant/src/notebookSuggestions.ts b/extensions/positron-assistant/src/notebookSuggestions.ts index d812a23fe04f..c2368bb4291f 100644 --- a/extensions/positron-assistant/src/notebookSuggestions.ts +++ b/extensions/positron-assistant/src/notebookSuggestions.ts @@ -57,7 +57,7 @@ export async function generateNotebookSuggestions( token: vscode.CancellationToken ): Promise { // Get the model to use for generation - const model = await getModel(participantService); + const model = await getModel(participantService, log); // Get notebook context const context = await positron.notebooks.getContext(); @@ -216,13 +216,56 @@ function normalizeSuggestion(suggestion: RawSuggestion): NotebookActionSuggestio /** * Get the language model to use for generation * Follows the same pattern as git.ts + * @param participantService Service for accessing the current chat model + * @param log Log output channel for debugging + * @returns The selected language model */ -async function getModel(participantService: ParticipantService): Promise { +async function getModel(participantService: ParticipantService, log: vscode.LogOutputChannel): Promise { + // Log all available models for debugging + const allModels = await vscode.lm.selectChatModels(); + log.debug(`[notebook-suggestions] Available models: ${allModels.length} total`); + allModels.forEach((model, index) => { + log.debug(`[notebook-suggestions] Model ${index + 1}: id="${model.id}", name="${model.name}", vendor="${model.vendor}"`); + }); + + // Check configuration setting first (highest priority) + const config = vscode.workspace.getConfiguration('positron.assistant'); + const configuredPatterns = config.get('notebookSuggestions.model') || []; + if (configuredPatterns.length > 0) { + log.debug(`[notebook-suggestions] Checking configured model patterns: ${JSON.stringify(configuredPatterns)}`); + // Iterate through patterns in order + for (const pattern of configuredPatterns) { + if (!pattern || pattern.trim() === '') { + continue; + } + log.debug(`[notebook-suggestions] Trying pattern: "${pattern}"`); + const patternLower = pattern.toLowerCase(); + // Try exact ID match first (case-sensitive for exact matches) + const exactMatch = allModels.find(m => m.id === pattern); + if (exactMatch) { + log.debug(`[notebook-suggestions] Using configured model (exact ID match): ${exactMatch.name} (${exactMatch.id})`); + return exactMatch; + } + // Try partial match on ID or name (case-insensitive) + const partialMatch = allModels.find(m => + m.id.toLowerCase().includes(patternLower) || m.name.toLowerCase().includes(patternLower) + ); + if (partialMatch) { + log.debug(`[notebook-suggestions] Using configured model (partial match): ${partialMatch.name} (${partialMatch.id})`); + return partialMatch; + } + log.debug(`[notebook-suggestions] Pattern "${pattern}" did not match any model, trying next pattern`); + } + log.warn(`[notebook-suggestions] None of the configured patterns matched any model, falling back to default selection`); + } + // Check for the latest chat session and use its model const sessionModelId = participantService.getCurrentSessionModel(); if (sessionModelId) { + log.debug(`[notebook-suggestions] Checking session model: ${sessionModelId}`); const models = await vscode.lm.selectChatModels({ 'id': sessionModelId }); if (models && models.length > 0) { + log.debug(`[notebook-suggestions] Using session model: ${models[0].name} (${models[0].id})`); return models[0]; } } @@ -230,17 +273,21 @@ async function getModel(participantService: ParticipantService): Promise 0) { + log.debug(`[notebook-suggestions] Using provider model: ${models[0].name} (${models[0].id})`); return models[0]; } } // Fall back to any available model + log.debug(`[notebook-suggestions] Falling back to first available model`); const [firstModel] = await vscode.lm.selectChatModels(); if (!firstModel) { throw new Error('No language model available'); } + log.debug(`[notebook-suggestions] Using fallback model: ${firstModel.name} (${firstModel.id})`); return firstModel; }