Skip to content

Commit 39bc0cd

Browse files
authored
Add notebook consoles; fix reload (#9550)
This change introduces 'notebook consoles', which are consoles that can be used as a sort of scratchpad for notebooks. A notebook console is attached to a notebook session; code you run in the console affects the notebook session, but doesn't appear in the notebook itself, making it ideal for one-off commands or computations better suited for a REPL than a throwaway cell. Notebook consoles appear in the Console tab with a notebook icon instead of a language logo, and the filename of the notebook listed beside them. <img width="283" height="161" alt="image" src="https://github.com/user-attachments/assets/0f4ea9da-2bc5-4f0b-8539-e6b13c833d76" /> While useful, these consoles can be kind of confusing to new users because it gives you two places to run code, and some things work a little differently when the kernel is in notebook (vs. REPL) mode. Consequently, the notebook console does not appear by default. The new command _Console: Show Notebook Console_ will trigger a notebook console; there's also a button on the new notebooks (and the old ones, not pictured): <img width="367" height="69" alt="image" src="https://github.com/user-attachments/assets/5a95779a-c158-45af-9a3e-9bff0cc85b9c" /> For users who always want one of these available, there's an option to auto-start one for every notebook: <img width="401" height="100" alt="image" src="https://github.com/user-attachments/assets/8564b4a7-6c2d-4ec5-8a01-10305e39710c" /> While I was in the code, I noticed that reload for notebook sessions has been broken for a while (see #9453). The problem was a fairly complicated start loop wherein restoring the session on reload triggered a pre-registration of the associated runtime, which triggered the associated notebook kernel to get created, which triggered a session start again. The fix there is to avoid trying to track the mapping between sessions and notebook URIs in the runtime kernel service, and to wait for sessions to be reconnected before trying to auto-start them for notebooks. Addresses #3801. Addresses #9453. ### Release Notes <!-- Optionally, replace `N/A` with text to be included in the next release notes. The `N/A` bullets are ignored. If you refer to one or more Positron issues, these issues are used to collect information about the feature or bugfix, such as the relevant language pack as determined by Github labels of type `lang: `. The note will automatically be tagged with the language. These notes are typically filled by the Positron team. If you are an external contributor, you may ignore this section. --> #### New Features - Add an option to attach a console to your notebook's R or Python session for one-off commands and interactive work (#3801) #### Bug Fixes - N/A ### QA Notes After you create the notebook console, any code you execute in the notebook will also appear in the notebook's console. This is normal; it's meant to help you see what's been run leading up to your current state. In notebook consoles, running commands that generate non-textual output (like graphs or HTML content) will often not work correctly, because in notebook mode this content typically goes to a cell. This change adds an E2E test to ensure that regression w/ reloading the window does not occur again.
1 parent 785570f commit 39bc0cd

File tree

23 files changed

+598
-116
lines changed

23 files changed

+598
-116
lines changed

extensions/positron-r/src/session-manager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,8 @@ export class RSessionManager implements vscode.Disposable {
107107
return;
108108
}
109109

110-
if (session.metadata.sessionMode !== positron.LanguageRuntimeSessionMode.Console) {
111-
throw Error(`Foreground session with ID ${sessionId} must be a console session.`);
110+
if (session.metadata.sessionMode === positron.LanguageRuntimeSessionMode.Background) {
111+
throw Error(`Foreground session with ID ${sessionId} must not be a background session.`);
112112
}
113113

114114
this._lastForegroundSessionId = session.metadata.sessionId;

src/vs/workbench/contrib/positronAssistant/test/browser/positronAssistantService.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { TestRuntimeStartupService } from '../../../../services/runtimeStartup/t
2222
import { IExecutionHistoryService } from '../../../../services/positronHistory/common/executionHistoryService.js';
2323
import { createTestPlotsServiceWithPlots } from '../../../../services/positronPlots/test/common/testPlotsServiceHelper.js';
2424
import { URI } from '../../../../../base/common/uri.js';
25+
import { IPositronConsoleService } from '../../../../services/positronConsole/browser/interfaces/positronConsoleService.js';
26+
import { PositronConsoleService } from '../../../../services/positronConsole/browser/positronConsoleService.js';
2527

2628
suite('PositronAssistantService', () => {
2729
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
@@ -41,6 +43,7 @@ suite('PositronAssistantService', () => {
4143
instantiationService.stub(IRuntimeStartupService, new TestRuntimeStartupService());
4244
createRuntimeServices(instantiationService, disposables);
4345
instantiationService.stub(IExecutionHistoryService, disposables.add(instantiationService.createInstance(ExecutionHistoryService)));
46+
instantiationService.stub(IPositronConsoleService, disposables.add(instantiationService.createInstance(PositronConsoleService)));
4447

4548
// Create test runtime sessions
4649
const runtime = createTestLanguageRuntimeMetadata(instantiationService, disposables);

src/vs/workbench/contrib/positronConsole/browser/components/consoleTabList.tsx

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { AnchorAlignment, AnchorAxisAlignment } from '../../../../../base/browse
2020
import { isMacintosh } from '../../../../../base/common/platform.js';
2121
import { PositronConsoleTabFocused } from '../../../../common/contextkeys.js';
2222
import { usePositronReactServicesContext } from '../../../../../base/browser/positronReactRendererContext.js';
23+
import { LanguageRuntimeSessionMode } from '../../../../services/languageRuntime/common/languageRuntimeService.js';
24+
import { basename } from '../../../../../base/common/path.js';
2325

2426
/**
2527
* The minimum width required for the delete action to be displayed on the console tab.
@@ -35,14 +37,30 @@ interface ConsoleTabProps {
3537
}
3638

3739
const ConsoleTab = ({ positronConsoleInstance, width, onChangeSession }: ConsoleTabProps) => {
40+
3841
// Context
3942
const services = usePositronReactServicesContext();
4043
const positronConsoleContext = usePositronConsoleContext();
4144

45+
// Compute session display name
46+
const isNotebookSession =
47+
positronConsoleInstance.sessionMetadata.sessionMode === LanguageRuntimeSessionMode.Notebook;
48+
const session = services.runtimeSessionService.getActiveSession(positronConsoleInstance.sessionId);
49+
let sessionDisplayName = '';
50+
if (session) {
51+
// Ask the session directly
52+
sessionDisplayName = session.session.getLabel();
53+
} else {
54+
// No session to ask, compute from the other metadata we have
55+
sessionDisplayName = isNotebookSession ?
56+
basename(positronConsoleInstance.sessionMetadata.notebookUri!.path) :
57+
positronConsoleInstance.sessionName;
58+
}
59+
4260
// State
4361
const [deleteDisabled, setDeleteDisabled] = useState(false);
4462
const [isRenamingSession, setIsRenamingSession] = useState(false);
45-
const [sessionName, setSessionName] = useState(positronConsoleInstance.sessionName);
63+
const [sessionName, setSessionName] = useState(sessionDisplayName);
4664

4765
// Refs
4866
const tabRef = useRef<HTMLDivElement>(null);
@@ -60,7 +78,24 @@ const ConsoleTab = ({ positronConsoleInstance, width, onChangeSession }: Console
6078
disposableStore.add(
6179
services.runtimeSessionService.onDidUpdateSessionName(session => {
6280
if (session.sessionId === positronConsoleInstance.sessionId) {
63-
setSessionName(session.dynState.sessionName);
81+
setSessionName(session.getLabel());
82+
}
83+
})
84+
85+
);
86+
87+
// Add the onDidUpdateNotebookSessionUri event handler.
88+
//
89+
// Notebook session URI changes can change what the label shows; if we
90+
// get one of these events for our session and there's a new label for
91+
// the session, pick it up.
92+
disposableStore.add(
93+
services.runtimeSessionService.onDidUpdateNotebookSessionUri(e => {
94+
if (e.sessionId === sessionId) {
95+
const session = services.runtimeSessionService.getActiveSession(sessionId);
96+
if (session) {
97+
setSessionName(session.session.getLabel());
98+
}
6499
}
65100
})
66101
);
@@ -89,20 +124,26 @@ const ConsoleTab = ({ positronConsoleInstance, width, onChangeSession }: Console
89124
};
90125

91126
/**
92-
* The mouse down handler for the parent element of the console tab instance.
93-
* This handler is used to show the context menu when the user right-clicks on a tab.
127+
* The mouse down handler for the parent element of the console tab
128+
* instance. This handler is used to show the context menu when the user
129+
* right-clicks on a tab.
130+
*
131+
* Notebook consoles can't be renamed, so we currently do not show a context
132+
* menu for them.
94133
*/
95-
const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {
96-
// Prevent the default action and stop the event from propagating.
97-
e.preventDefault();
98-
e.stopPropagation();
134+
const handleMouseDown = positronConsoleInstance.sessionMetadata.sessionMode ===
135+
LanguageRuntimeSessionMode.Notebook ? undefined :
136+
(e: MouseEvent<HTMLDivElement>) => {
137+
// Prevent the default action and stop the event from propagating.
138+
e.preventDefault();
139+
e.stopPropagation();
99140

100-
// Show the context menu when the user right-clicks on a tab or
101-
// when the user executes ctrl + left-click on macOS
102-
if ((e.button === 0 && isMacintosh && e.ctrlKey) || e.button === 2) {
103-
showContextMenu(e.clientX, e.clientY);
141+
// Show the context menu when the user right-clicks on a tab or
142+
// when the user executes ctrl + left-click on macOS
143+
if ((e.button === 0 && isMacintosh && e.ctrlKey) || e.button === 2) {
144+
showContextMenu(e.clientX, e.clientY);
145+
}
104146
}
105-
}
106147

107148
/**
108149
* Shows the context menu when a user right-clicks on a console instance tab.
@@ -328,10 +369,16 @@ const ConsoleTab = ({ positronConsoleInstance, width, onChangeSession }: Console
328369
onMouseDown={handleMouseDown}
329370
>
330371
<ConsoleInstanceState positronConsoleInstance={positronConsoleInstance} />
331-
<img
332-
className='icon'
333-
src={`data:image/svg+xml;base64,${positronConsoleInstance.runtimeMetadata.base64EncodedIconSvg}`}
334-
/>
372+
{
373+
!isNotebookSession &&
374+
<img
375+
className='icon'
376+
src={`data:image/svg+xml;base64,${positronConsoleInstance.runtimeMetadata.base64EncodedIconSvg}`}
377+
/>
378+
}
379+
{isNotebookSession &&
380+
<span className='codicon codicon-notebook icon'></span>
381+
}
335382
{isRenamingSession ? (
336383
<input
337384
ref={inputRef}

src/vs/workbench/contrib/positronConsole/browser/positronConsoleActions.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.j
1717
import { ILanguageService } from '../../../../editor/common/languages/language.js';
1818
import { PositronConsoleFocused } from '../../../common/contextkeys.js';
1919
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
20-
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
20+
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
2121
import { IViewsService } from '../../../services/views/common/viewsService.js';
2222
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
2323
import { IEditorService } from '../../../services/editor/common/editorService.js';
@@ -33,6 +33,7 @@ import { IExecutionHistoryService } from '../../../services/positronHistory/comm
3333
import { CodeAttributionSource, IConsoleCodeAttribution } from '../../../services/positronConsole/common/positronConsoleCodeExecution.js';
3434
import { ICommandService } from '../../../../platform/commands/common/commands.js';
3535
import { POSITRON_NOTEBOOK_CELL_EDITOR_FOCUSED } from '../../../services/positronNotebook/browser/ContextKeysManager.js';
36+
import { getContextFromActiveEditor } from '../../notebook/browser/controller/coreActions.js';
3637

3738
/**
3839
* Positron console command ID's.
@@ -46,6 +47,7 @@ const enum PositronConsoleCommandId {
4647
NavigateInputHistoryDown = 'workbench.action.positronConsole.navigateInputHistoryDown',
4748
NavigateInputHistoryUp = 'workbench.action.positronConsole.navigateInputHistoryUp',
4849
NavigateInputHistoryUpUsingPrefixMatch = 'workbench.action.positronConsole.navigateInputHistoryUpUsingPrefixMatch',
50+
ShowNotebookConsole = 'workbench.action.positronConsole.showNotebookConsole'
4951
}
5052

5153
/**
@@ -759,4 +761,58 @@ export function registerPositronConsoleActions() {
759761
}
760762
}
761763
});
764+
765+
/**
766+
* Register the action to show the notebook console.
767+
*
768+
* This will create a new console for the notebook if it doesn't have one,
769+
* and will raise and focus the existing console if one exists
770+
*/
771+
registerAction2(class extends Action2 {
772+
/**
773+
* Constructor.
774+
*/
775+
constructor() {
776+
super({
777+
id: PositronConsoleCommandId.ShowNotebookConsole,
778+
title: {
779+
value: localize('workbench.action.positronConsole.showNotebookConsole', "Show Notebook Console"),
780+
original: 'Show Notebook Console'
781+
},
782+
f1: true,
783+
category,
784+
menu: [
785+
{
786+
// Add an entry to the notebook toolbar to show the
787+
// notebook console
788+
id: MenuId.NotebookToolbar,
789+
group: 'notebookConsole',
790+
when: ContextKeyExpr.equals('config.notebook.globalToolbar', true),
791+
order: 1
792+
}
793+
]
794+
});
795+
}
796+
797+
/**
798+
* Runs action and creates the notebook console.
799+
*
800+
* @param accessor The services accessor.
801+
*/
802+
async run(accessor: ServicesAccessor) {
803+
// Get services
804+
const positronConsoleService = accessor.get(IPositronConsoleService);
805+
const editorService = accessor.get(IEditorService);
806+
const notificationService = accessor.get(INotificationService);
807+
808+
// Figure out the URI of the active notebook and tell the console
809+
// service to start a console attached to the appropriate session
810+
const context = getContextFromActiveEditor(editorService);
811+
if (context) {
812+
positronConsoleService.showNotebookConsole(context.notebookEditor.textModel.uri, true);
813+
} else {
814+
notificationService.info(localize('positron.noActiveNotebook', "No active notebook; run this command with a notebook open in an editor to see its console."));
815+
}
816+
}
817+
});
762818
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (C) 2025 Posit Software, PBC. All rights reserved.
3+
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { localize, localize2 } from '../../../../nls.js';
7+
import { Action2, MenuId } from '../../../../platform/actions/common/actions.js';
8+
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
9+
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
10+
import { ILogService } from '../../../../platform/log/common/log.js';
11+
import { INotificationService } from '../../../../platform/notification/common/notification.js';
12+
import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js';
13+
import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js';
14+
import { IEditorService } from '../../../services/editor/common/editorService.js';
15+
import { IPositronConsoleService } from '../../../services/positronConsole/browser/interfaces/positronConsoleService.js';
16+
import { IRuntimeSessionService } from '../../../services/runtimeSession/common/runtimeSessionService.js';
17+
import { CELL_TITLE_CELL_GROUP_ID, CellToolbarOrder, getContextFromActiveEditor } from '../../notebook/browser/controller/coreActions.js';
18+
import { executeIcon } from '../../notebook/browser/notebookIcons.js';
19+
import { NOTEBOOK_EDITOR_ID } from '../../notebook/common/notebookCommon.js';
20+
import { CodeAttributionSource } from '../../../services/positronConsole/common/positronConsoleCodeExecution.js';
21+
import { RuntimeCodeExecutionMode } from '../../../services/languageRuntime/common/languageRuntimeService.js';
22+
import { POSITRON_NOTEBOOK_EDITOR_ID } from '../common/positronNotebookCommon.js';
23+
import { NOTEBOOK_CELL_TYPE } from '../../notebook/common/notebookContextKeys.js';
24+
25+
export const SELECT_KERNEL_ID_POSITRON = 'positronNotebook.selectKernel';
26+
27+
/**
28+
* An action that executes the selected text in a notebook cell in the
29+
* associated console for the notebook.
30+
*/
31+
export class ExecuteSelectionInConsoleAction extends Action2 {
32+
33+
constructor() {
34+
super({
35+
id: 'positronNotebook.executeSelectionInConsole',
36+
category: localize2('notebook.category', 'Notebook'),
37+
title: localize2('positronNotebookActions.executeSelectionInConsole', 'Execute Selection in Console'),
38+
icon: executeIcon,
39+
f1: true,
40+
// Only enable if the active editor is a notebook (Positron or built-in)
41+
precondition: ContextKeyExpr.or(
42+
ContextKeyExpr.equals('activeEditor', POSITRON_NOTEBOOK_EDITOR_ID),
43+
ContextKeyExpr.equals('activeEditor', NOTEBOOK_EDITOR_ID),
44+
),
45+
// Show in the cell context menu, but only for code cells and when there's a selection
46+
menu: [
47+
{
48+
id: MenuId.NotebookCellTitle,
49+
// We intentionally re-use the "Execute Cell and Below"
50+
// order so that this appears next to it in the menu w/o us
51+
// needing to reorder any menus from upstream
52+
order: CellToolbarOrder.ExecuteCellAndBelow,
53+
group: CELL_TITLE_CELL_GROUP_ID,
54+
when: ContextKeyExpr.and(
55+
NOTEBOOK_CELL_TYPE.isEqualTo('code'),
56+
EditorContextKeys.hasNonEmptySelection
57+
),
58+
}
59+
]
60+
});
61+
}
62+
63+
async run(accessor: ServicesAccessor): Promise<boolean> {
64+
// Get services
65+
const positronConsoleService = accessor.get(IPositronConsoleService);
66+
const editorService = accessor.get(IEditorService);
67+
const runtimeSessionService = accessor.get(IRuntimeSessionService);
68+
const logService = accessor.get(ILogService);
69+
const notificationService = accessor.get(INotificationService);
70+
71+
// Figure out the URI of the active notebook
72+
const context = getContextFromActiveEditor(editorService);
73+
if (!context) {
74+
// This should never happen because of the precondition on the
75+
// action, but log if it does
76+
logService.warn('No active notebook editor found when trying to execute selection in console');
77+
return false;
78+
}
79+
const notebookUri = context.notebookEditor.textModel.uri;
80+
81+
// Look up the session for the notebook
82+
const session = runtimeSessionService.getNotebookSessionForNotebookUri(notebookUri);
83+
if (!session) {
84+
// Just warn and return if there's no session; this should be rare.
85+
// If we wanted to be nicer here, we could try to orchestrate an
86+
// auto-start.
87+
notificationService.warn(localize('positron.noNotebookSession', "Cannot execute selection; no interpreter session is running for notebook {0}", notebookUri.toString()));
88+
return false;
89+
}
90+
91+
// Show (and possibly create) the console for the notebook
92+
positronConsoleService.showNotebookConsole(notebookUri, false /*focus*/);
93+
94+
// Figure out the selected text, or the entire line if no selection
95+
const selectedText = this.getSelectedText(editorService);
96+
if (!selectedText) {
97+
// It's weird to run this with nothing selected and no active line,
98+
// but just log and return
99+
logService.warn('Execute Selection in Console: No text selected and no active line found');
100+
return false;
101+
}
102+
103+
// Ask the console service to execute the text
104+
positronConsoleService.executeCode(
105+
session.runtimeMetadata.languageId,
106+
session.sessionId,
107+
selectedText,
108+
{ source: CodeAttributionSource.Interactive },
109+
false, // focus
110+
true, // allow incomplete
111+
RuntimeCodeExecutionMode.Interactive
112+
);
113+
return true;
114+
}
115+
116+
/**
117+
* Gets the selected text from the active editor, or the entire line if no selection
118+
*/
119+
private getSelectedText(editorService: IEditorService): string | null {
120+
let editor = editorService.activeTextEditorControl;
121+
if (!editor) {
122+
return null;
123+
}
124+
125+
// Ensure we have a code editor with a model
126+
if (!isCodeEditor(editor) || !editor.hasModel()) {
127+
return null;
128+
}
129+
130+
const selection = editor.getSelection();
131+
if (!selection) {
132+
return null;
133+
}
134+
135+
const model = editor.getModel();
136+
137+
// If there's a selection, get the selected text
138+
if (!selection.isEmpty()) {
139+
return model.getValueInRange(selection);
140+
} else {
141+
// No selection - get the entire line where the cursor is
142+
const position = selection.getStartPosition();
143+
const lineContent = model.getLineContent(position.lineNumber);
144+
return lineContent.trim() || null;
145+
}
146+
}
147+
}
148+

src/vs/workbench/contrib/positronNotebook/browser/PositronNotebookHeader.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export function PositronNotebookHeader({ notebookInstance }: { notebookInstance:
2828
fullLabel={(() => localize('clearAllCellOutputsLong', 'Clear All Cell Outputs'))()}
2929
label={(() => localize('clearAllCellOutputsShort', 'Clear Outputs'))()}
3030
onClick={() => { notebookInstance.clearAllCellOutputs(); }} />
31+
<IconedButton
32+
codicon='terminal'
33+
fullLabel={(() => localize('showNotebookConsoleLong', 'Create or Focus Notebook Console'))()}
34+
label={(() => localize('showNotebookConsole', 'Show Console'))()}
35+
onClick={() => { notebookInstance.showNotebookConsole(); }} />
3136
<div style={{ marginLeft: 'auto' }}></div>
3237
<AddCodeCellButton index={0} notebookInstance={notebookInstance} />
3338
<AddMarkdownCellButton index={0} notebookInstance={notebookInstance} />

0 commit comments

Comments
 (0)