diff --git a/extensions/positron-python/src/client/positron/session.ts b/extensions/positron-python/src/client/positron/session.ts index 0c37a536f974..7da39621f067 100644 --- a/extensions/positron-python/src/client/positron/session.ts +++ b/extensions/positron-python/src/client/positron/session.ts @@ -225,14 +225,22 @@ export class PythonRuntimeSession implements positron.LanguageRuntimeSession, vs errorBehavior: positron.RuntimeErrorBehavior, codeLocation?: positron.Utf8Location, executionMetadata?: Record, - ): void { + ): Promise { if (this._kernel) { if (this._isUninstallBundledPackageCommand(code, id)) { // It's an attempt to uninstall a bundled package, don't execute. - return; + return Promise.resolve(); } - this._kernel.execute(code, id, mode, errorBehavior, codeLocation, executionMetadata); + // Return the kernel's execution promise so a rejection propagates + // back to the caller. This matters for the `Unprocessed` mode: the + // supervisor checks completeness and rejects (with a + // `CodeIncompleteError`) when the code is incomplete, and the + // console relies on that rejection to show a continuation prompt. + // Dropping it here would leave the console waiting forever. + return Promise.resolve( + this._kernel.execute(code, id, mode, errorBehavior, codeLocation, executionMetadata), + ); } else { throw new Error(`Cannot execute '${code}'; kernel not started`); } diff --git a/extensions/positron-python/src/test/mocks/pst/index.ts b/extensions/positron-python/src/test/mocks/pst/index.ts index 98f6f9be28e2..336613f5d9f6 100644 --- a/extensions/positron-python/src/test/mocks/pst/index.ts +++ b/extensions/positron-python/src/test/mocks/pst/index.ts @@ -46,6 +46,12 @@ export enum RuntimeCodeExecutionMode { * Stored in history: No */ Silent = 'silent', + + /** + * Unprocessed code execution: behaves like `Interactive`, but the code has + * not been checked for completeness; the session checks it before executing. + */ + Unprocessed = 'unprocessed', } export enum RuntimeErrorBehavior { diff --git a/extensions/positron-python/src/test/positron/session.unit.test.ts b/extensions/positron-python/src/test/positron/session.unit.test.ts index da6b5bdd3481..0e0c993850c9 100644 --- a/extensions/positron-python/src/test/positron/session.unit.test.ts +++ b/extensions/positron-python/src/test/positron/session.unit.test.ts @@ -298,4 +298,31 @@ suite('Python Runtime Session', () => { const state = messages[1] as positron.LanguageRuntimeState; assert.strictEqual(state.state, positron.RuntimeOnlineState.Idle); }); + + test('Execute: propagates the kernel execute promise (e.g. incomplete code)', async () => { + sinon.stub(fs, 'readdirSync').returns(['ipykernel']); + + const session = createSession(positron.LanguageRuntimeSessionMode.Console); + await session.start(); + + // For Unprocessed code the supervisor checks completeness itself and + // rejects with a CodeIncompleteError when the code is incomplete. That + // rejection must propagate back through execute() so the console can + // show a continuation prompt instead of hanging forever. + const incompleteError = new Error('Code fragment is incomplete'); + incompleteError.name = 'CodeIncompleteError'; + sinon.stub(kernel, 'execute').rejects(incompleteError); + + await assert.rejects( + Promise.resolve( + session.execute( + 'def f():', + 'execute-id', + positron.RuntimeCodeExecutionMode.Unprocessed, + positron.RuntimeErrorBehavior.Continue, + ), + ), + (err: Error) => err.name === 'CodeIncompleteError', + ); + }); }); diff --git a/extensions/positron-r/src/session.ts b/extensions/positron-r/src/session.ts index 3fe90df5d08d..69bacd990fff 100644 --- a/extensions/positron-r/src/session.ts +++ b/extensions/positron-r/src/session.ts @@ -277,9 +277,13 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa errorBehavior: positron.RuntimeErrorBehavior, codeLocation?: positron.Utf8Location, executionMetadata?: Record - ): void { + ): Promise { if (this._kernel) { - this._kernel.execute(code, id, mode, errorBehavior, codeLocation, executionMetadata); + // Return the kernel's execution promise so a rejection (e.g. an + // Unprocessed submission found to be incomplete) propagates back to + // the caller instead of surfacing as an unhandled rejection. + return Promise.resolve( + this._kernel.execute(code, id, mode, errorBehavior, codeLocation, executionMetadata)); } else { throw new Error(`Cannot execute '${code}'; kernel not started`); } diff --git a/extensions/positron-supervisor/src/KallichoreSession.ts b/extensions/positron-supervisor/src/KallichoreSession.ts index 0980745c62a8..94025d523a6f 100644 --- a/extensions/positron-supervisor/src/KallichoreSession.ts +++ b/extensions/positron-supervisor/src/KallichoreSession.ts @@ -16,6 +16,7 @@ import { KernelInfoReply, KernelInfoRequest } from './jupyter/KernelInfoRequest' import { Barrier, PromiseHandles, withTimeout } from './async'; import { ExecuteRequest, JupyterExecuteRequest } from './jupyter/ExecuteRequest'; import { IsCompleteRequest, JupyterIsCompleteRequest } from './jupyter/IsCompleteRequest'; +import { CODE_INCOMPLETE_ERROR, EXECUTION_CANCELLED_ERROR, shouldExecuteAfterCompletenessCheck, shouldSendKernelInterrupt } from './completenessHelpers'; import { CommInfoRequest } from './jupyter/CommInfoRequest'; import { JupyterCommOpen } from './jupyter/JupyterCommOpen'; import { CommOpenCommand } from './jupyter/CommOpenCommand'; @@ -134,6 +135,14 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { /** The current runtime state of this session */ private _runtimeState: positron.RuntimeState = positron.RuntimeState.Uninitialized; + /** + * Pending unprocessed (Flow 2) completeness checks, keyed by execution ID. + * The value aborts the pending check, causing the corresponding `execute()` + * call to reject with an `ExecutionCancelledError`. See `execute()` and + * `interrupt()`. + */ + private _pendingUnprocessedExecutions: Map void> = new Map(); + /** A map of pending RPCs, used to pair up requests and replies */ private _pendingRequests: Map> = new Map(); @@ -807,20 +816,35 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { * @param mode The execution mode * @param errorBehavior What to do if an error occurs */ - execute( + async execute( code: string, id: string, mode: positron.RuntimeCodeExecutionMode, errorBehavior: positron.RuntimeErrorBehavior, codeLocation?: positron.Utf8Location, executionMetadata?: Record - ): void { - - // Translate the parameters into a Jupyter execute request + ): Promise { + + // For unprocessed code, check completeness ourselves before executing. + // This eliminates a client-to-(remote-)supervisor roundtrip: the + // console does not have to send a separate is_complete_request and wait + // for its reply before sending the execute_request. If the code is + // incomplete, throw so the console can show the continuation prompt; if + // the submission is cancelled while we wait, throw a cancellation error. + if (mode === positron.RuntimeCodeExecutionMode.Unprocessed) { + await this.checkUnprocessedCompleteness(code, id); + } + + // Translate the parameters into a Jupyter execute request. `Unprocessed` + // behaves exactly like `Interactive` on the wire (displayed to the user, + // stored in history). + const interactiveLike = + mode === positron.RuntimeCodeExecutionMode.Interactive || + mode === positron.RuntimeCodeExecutionMode.Unprocessed; const request: JupyterExecuteRequest = { code, silent: mode === positron.RuntimeCodeExecutionMode.Silent, - store_history: mode === positron.RuntimeCodeExecutionMode.Interactive, + store_history: interactiveLike, user_expressions: {}, allow_stdin: true, stop_on_error: errorBehavior === positron.RuntimeErrorBehavior.Stop, @@ -845,7 +869,15 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { }; } - // Create and send the execute request. + // Create and send the execute request. The promise returned by + // `execute()` signals ACCEPTANCE of the code (the request has been + // dispatched to the kernel), not completion, so we do not await the + // reply here. The reply paired with `ExecuteRequest` is `execute_result`, + // which the kernel only emits for code that produces a result value; + // awaiting it would hang forever on assignments, `print(...)`, and any + // other statement without a value. For `Unprocessed` code the + // completeness check above already established acceptance (rejecting on + // incomplete/cancelled). The reply is logged out of band. const execute = new ExecuteRequest(id, request, cellId as string); this.sendRequest(execute).then((reply) => { this.log(`Execution result: ${JSON.stringify(reply)}`, vscode.LogLevel.Debug); @@ -856,6 +888,43 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { }); } + /** + * Checks whether an unprocessed (Flow 2) code fragment is complete before + * executing it. Sends an `is_complete_request` and awaits the reply. Rejects + * (without sending the execute request) if the code is incomplete or if the + * submission is cancelled via `interrupt()` while waiting. + * + * @param code The code to check. + * @param id The execution ID, used to key the cancellation entry. + */ + private async checkUnprocessedCompleteness(code: string, id: string): Promise { + // Register a cancellation entry so interrupt() can abort this check. + const abortPromise = new Promise((_resolve, reject) => { + this._pendingUnprocessedExecutions.set(id, () => { + const err = new Error( + `Execution ${id} was cancelled before it was submitted`); + err.name = EXECUTION_CANCELLED_ERROR; + reject(err); + }); + }); + + try { + // Send the is_complete_request and race it against cancellation. + const request: JupyterIsCompleteRequest = { code }; + const isComplete = new IsCompleteRequest(request); + const reply = await Promise.race([this.sendRequest(isComplete), abortPromise]); + + if (!shouldExecuteAfterCompletenessCheck(reply.status)) { + const err = new Error( + `Code fragment of length ${code.length} is incomplete`); + err.name = CODE_INCOMPLETE_ERROR; + throw err; + } + } finally { + this._pendingUnprocessedExecutions.delete(id); + } + } + /** * Tests whether a code fragment is complete. * @param code The code to test @@ -1741,6 +1810,26 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession { * been sent. Note that the kernel may not be interrupted immediately. */ async interrupt(): Promise { + // Abort any pending unprocessed (Flow 2) completeness checks first; each + // abort makes the corresponding execute() reject with a cancellation + // error. Capture the count before clearing so we can decide whether the + // HTTP interrupt is still needed. + const pendingUnprocessedCount = this._pendingUnprocessedExecutions.size; + if (pendingUnprocessedCount > 0) { + for (const abort of Array.from(this._pendingUnprocessedExecutions.values())) { + abort(); + } + this._pendingUnprocessedExecutions.clear(); + } + + // If we only had pending completeness checks to abort and the kernel is + // not currently busy, there is nothing running to interrupt; skip the + // HTTP interrupt (and the Interrupting state transition). + const kernelBusy = this._runtimeState === positron.RuntimeState.Busy; + if (!shouldSendKernelInterrupt(kernelBusy, pendingUnprocessedCount)) { + return; + } + // Mark the session as interrupting this.onStateChange(positron.RuntimeState.Interrupting, 'interrupting kernel'); diff --git a/extensions/positron-supervisor/src/completenessHelpers.ts b/extensions/positron-supervisor/src/completenessHelpers.ts new file mode 100644 index 000000000000..e902616c4525 --- /dev/null +++ b/extensions/positron-supervisor/src/completenessHelpers.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/** The status returned by a Jupyter `is_complete_request`. */ +export type IsCompleteStatus = 'complete' | 'incomplete' | 'invalid' | 'unknown'; + +/** The error name thrown when unprocessed code is found to be incomplete. */ +export const CODE_INCOMPLETE_ERROR = 'CodeIncompleteError'; + +/** The error name thrown when an unprocessed submission is cancelled. */ +export const EXECUTION_CANCELLED_ERROR = 'ExecutionCancelledError'; + +/** + * Decides whether code should execute after an `is_complete_request` check. + * + * Only `incomplete` blocks execution; `complete`, `invalid`, and `unknown` all + * proceed so the interpreter can surface any syntax error itself (matching the + * front-end's historical behavior for invalid/unknown fragments). + * + * @param status The status from the is_complete reply. + * @returns True if the code should be executed, false if it is incomplete. + */ +export function shouldExecuteAfterCompletenessCheck(status: IsCompleteStatus): boolean { + return status !== 'incomplete'; +} + +/** + * Decides whether a kernel interrupt (the HTTP interrupt call) should be sent. + * + * When there are pending unprocessed completeness checks, those are aborted + * locally; the HTTP interrupt is only needed when the kernel is actually busy + * executing something. When there are no pending checks, the normal interrupt + * behavior is preserved. + * + * @param kernelBusy Whether the kernel is currently busy executing code. + * @param pendingUnprocessedCount The number of pending unprocessed checks that + * were aborted. + * @returns True if the HTTP interrupt should be sent. + */ +export function shouldSendKernelInterrupt(kernelBusy: boolean, pendingUnprocessedCount: number): boolean { + if (pendingUnprocessedCount > 0) { + return kernelBusy; + } + return true; +} diff --git a/extensions/positron-supervisor/src/test/completenessHelpers.test.ts b/extensions/positron-supervisor/src/test/completenessHelpers.test.ts new file mode 100644 index 000000000000..46c158259a5b --- /dev/null +++ b/extensions/positron-supervisor/src/test/completenessHelpers.test.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { shouldExecuteAfterCompletenessCheck, shouldSendKernelInterrupt } from '../completenessHelpers'; + +suite('completenessHelpers', () => { + suite('shouldExecuteAfterCompletenessCheck', () => { + test('executes complete code', () => { + assert.strictEqual(shouldExecuteAfterCompletenessCheck('complete'), true); + }); + + test('does not execute incomplete code', () => { + assert.strictEqual(shouldExecuteAfterCompletenessCheck('incomplete'), false); + }); + + test('executes invalid code (so the interpreter surfaces the error)', () => { + assert.strictEqual(shouldExecuteAfterCompletenessCheck('invalid'), true); + }); + + test('executes unknown code (so the interpreter surfaces any error)', () => { + assert.strictEqual(shouldExecuteAfterCompletenessCheck('unknown'), true); + }); + }); + + suite('shouldSendKernelInterrupt', () => { + test('sends the interrupt when there are no pending checks (normal interrupt)', () => { + assert.strictEqual(shouldSendKernelInterrupt(true, 0), true); + assert.strictEqual(shouldSendKernelInterrupt(false, 0), true); + }); + + test('sends the interrupt when there are pending checks and the kernel is busy', () => { + assert.strictEqual(shouldSendKernelInterrupt(true, 1), true); + }); + + test('skips the interrupt when there are only pending checks to abort and the kernel is idle', () => { + assert.strictEqual(shouldSendKernelInterrupt(false, 1), false); + assert.strictEqual(shouldSendKernelInterrupt(false, 3), false); + }); + }); +}); diff --git a/src/positron-dts/positron.d.ts b/src/positron-dts/positron.d.ts index f7ee06b642b5..823f7fb9fc78 100644 --- a/src/positron-dts/positron.d.ts +++ b/src/positron-dts/positron.d.ts @@ -177,7 +177,24 @@ declare module 'positron' { * Combined with pending code: No * Stored in history: No */ - Silent = 'silent' + Silent = 'silent', + + /** + * Unprocessed code execution: behaves exactly like `Interactive` + * (displayed to the user, stored in history), except the code has NOT + * been checked for completeness. A session receiving this mode must + * check completeness itself before executing; if the code is + * incomplete it must not execute it and must reject the `execute()` + * call with an error whose `name` is `'CodeIncompleteError'`. Sessions + * that cannot check completeness should treat `Unprocessed` exactly as + * `Interactive` (the interpreter will surface a syntax error for + * incomplete code). + * + * Displayed to user: Yes + * Combined with pending code: Yes + * Stored in history: Yes + */ + Unprocessed = 'unprocessed' } /** @@ -1527,6 +1544,13 @@ declare module 'positron' { * @param codeLocation Optionally, the location of `code` in the source editor. * @param executionMetadata Optionally, a record of additional metadata to associate with this execution. * Note: The errorBehavior parameter is currently ignored by kernels + * + * The returned Thenable (if any) signals ACCEPTANCE of the code for + * execution, not completion of the execution. When `mode` is + * `RuntimeCodeExecutionMode.Unprocessed`, the session must check the + * code for completeness first; if it is incomplete, reject with an + * error whose `name` is `'CodeIncompleteError'`. Returning `void` + * (the historical behavior) is treated as immediate acceptance. */ execute( code: string, @@ -1535,7 +1559,7 @@ declare module 'positron' { errorBehavior: RuntimeErrorBehavior, codeLocation?: Utf8Location, executionMetadata?: Record, - ): void; + ): Thenable | void; /** * Shut down the runtime; returns a Thenable that resolves when the diff --git a/src/vs/editor/contrib/positronInputBoundaries/browser/provideInputBoundaries.ts b/src/vs/editor/contrib/positronInputBoundaries/browser/provideInputBoundaries.ts new file mode 100644 index 000000000000..8bc156d581cb --- /dev/null +++ b/src/vs/editor/contrib/positronInputBoundaries/browser/provideInputBoundaries.ts @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { LanguageFeatureRegistry } from '../../../common/languageFeatureRegistry.js'; +import * as languages from '../../../common/languages.js'; +import { isCancellationError, onUnexpectedExternalError } from '../../../../base/common/errors.js'; +import { ITextModel } from '../../../common/model.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { raceCancellationError } from '../../../../base/common/async.js'; + +/** + * Queries the input boundary providers registered for the given model and + * returns the boundaries from the first provider that yields a result. + * + * @param registry The input boundary provider registry. + * @param model The text model whose full range should be queried. + * @param token A cancellation token. + * @returns The boundaries from the first matching provider, or `undefined` when + * no provider is registered for the model's language or none returned a + * result. Rejects with a cancellation error if the token is cancelled. + */ +export async function provideInputBoundaries( + registry: LanguageFeatureRegistry, + model: ITextModel, + token: CancellationToken +): Promise { + const providers = registry.ordered(model); + if (providers.length === 0) { + return undefined; + } + + const range = model.getFullModelRange(); + for (const provider of providers) { + try { + const boundaries = await raceCancellationError( + Promise.resolve(provider.provideInputBoundaries(model, range, token)), + token + ); + if (boundaries) { + return boundaries; + } + } catch (err) { + if (isCancellationError(err)) { + throw err; + } + // Try the next provider on any non-cancellation error. + onUnexpectedExternalError(err); + } + } + return undefined; +} + +/** + * A single executable code fragment produced from a `complete` input boundary, + * carrying its line range within the submitted code so callers can attribute it + * back to its source lines. + */ +export interface IInputBoundaryFragment { + /** The fragment code (the boundary's lines joined with `\n`). */ + readonly code: string; + + /** The fragment's 0-based start line within the submitted code. */ + readonly startLine: number; + + /** The fragment's 0-based end line (exclusive) within the submitted code. */ + readonly endLine: number; +} + +/** + * The result of converting input boundaries into executable code fragments. + */ +export interface IInputBoundaryFragments { + /** + * The code fragments for the `complete` boundaries, in order. Each fragment + * is the boundary's lines joined with `\n`, tagged with its line range. + */ + readonly fragments: IInputBoundaryFragment[]; + + /** True if any boundary was `incomplete`. */ + readonly incomplete: boolean; + + /** True if any boundary was `invalid`. */ + readonly invalid: boolean; +} + +/** + * Converts input boundaries into executable code fragments. + * + * Boundaries are zero-indexed, end-exclusive line ranges over `code`'s lines. + * They must be contiguous (each boundary's `start` equal to the previous + * boundary's `end`), begin at line 0, and end at the last line. `whitespace` + * boundaries are skipped. A `complete` boundary's fragment is its lines joined + * with `\n`. `incomplete` and `invalid` boundaries set the corresponding flags + * but do not contribute fragments (the caller decides what to do with them). + * + * @param code The submitted code. + * @param boundaries The boundaries returned by a provider. + * @returns The fragments and the incomplete/invalid flags. + * @throws If the boundaries are malformed (wrong shape, out of range, or + * non-contiguous). Callers treat a throw as "no usable provider result". + */ +export function codeFragmentsFromBoundaries( + code: string, + boundaries: languages.IInputBoundary[] +): IInputBoundaryFragments { + if (!Array.isArray(boundaries)) { + throw new Error('Input boundaries must be an array'); + } + + const lines = code.split('\n'); + const fragments: IInputBoundaryFragment[] = []; + let incomplete = false; + let invalid = false; + let nextStart = 0; + + for (const boundary of boundaries) { + if (!boundary || typeof boundary !== 'object') { + throw new Error('Input boundary must be an object'); + } + + const kind = boundary.kind; + if (kind !== 'whitespace' && + kind !== 'complete' && + kind !== 'incomplete' && + kind !== 'invalid' + ) { + throw new Error(`Unknown input boundary kind: ${kind}`); + } + + const range = boundary.range; + if (!range || + !Number.isInteger(range.start) || + !Number.isInteger(range.end) || + range.start !== nextStart || + range.start < 0 || + range.end < range.start || + range.end > lines.length + ) { + throw new Error('Input boundary range is out of range or non-contiguous'); + } + + // A non-whitespace boundary must span at least one line. + if (kind !== 'whitespace' && range.start === range.end) { + throw new Error('Non-whitespace input boundary must span at least one line'); + } + + nextStart = range.end; + + switch (kind) { + case 'whitespace': + break; + case 'complete': { + const fragment = lines.slice(range.start, range.end).join('\n'); + if (fragment.length > 0) { + fragments.push({ code: fragment, startLine: range.start, endLine: range.end }); + } + break; + } + case 'incomplete': + incomplete = true; + break; + case 'invalid': + invalid = true; + break; + } + } + + // The boundaries must cover every line of the code. + if (nextStart !== lines.length) { + throw new Error('Input boundaries do not cover the entire code range'); + } + + return { fragments, incomplete, invalid }; +} diff --git a/src/vs/editor/contrib/positronInputBoundaries/test/browser/provideInputBoundaries.vitest.ts b/src/vs/editor/contrib/positronInputBoundaries/test/browser/provideInputBoundaries.vitest.ts new file mode 100644 index 000000000000..a97d05fced7a --- /dev/null +++ b/src/vs/editor/contrib/positronInputBoundaries/test/browser/provideInputBoundaries.vitest.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/// + +import { IInputBoundary } from '../../../../common/languages.js'; +import { codeFragmentsFromBoundaries } from '../../browser/provideInputBoundaries.js'; + +/** + * Helper to build a boundary with a zero-indexed, end-exclusive line range. + */ +function boundary(start: number, end: number, kind: IInputBoundary['kind']): IInputBoundary { + return { range: { start, end }, kind }; +} + +describe('codeFragmentsFromBoundaries', () => { + it('returns a single complete fragment', () => { + const code = 'x <- 1'; + const result = codeFragmentsFromBoundaries(code, [boundary(0, 1, 'complete')]); + expect(result).toEqual({ + fragments: [{ code: 'x <- 1', startLine: 0, endLine: 1 }], + incomplete: false, + invalid: false, + }); + }); + + it('returns multiple complete fragments in order with their line ranges', () => { + const code = 'x <- 1\ny <- 2\nz <- 3'; + const result = codeFragmentsFromBoundaries(code, [ + boundary(0, 1, 'complete'), + boundary(1, 2, 'complete'), + boundary(2, 3, 'complete'), + ]); + expect(result).toEqual({ + fragments: [ + { code: 'x <- 1', startLine: 0, endLine: 1 }, + { code: 'y <- 2', startLine: 1, endLine: 2 }, + { code: 'z <- 3', startLine: 2, endLine: 3 }, + ], + incomplete: false, + invalid: false, + }); + }); + + it('joins a multi-line complete fragment with newlines and spans its line range', () => { + const code = 'if (x) {\n y\n}\nz'; + const result = codeFragmentsFromBoundaries(code, [ + boundary(0, 3, 'complete'), + boundary(3, 4, 'complete'), + ]); + expect(result.fragments).toEqual([ + { code: 'if (x) {\n y\n}', startLine: 0, endLine: 3 }, + { code: 'z', startLine: 3, endLine: 4 }, + ]); + }); + + it('skips whitespace boundaries but keeps line ranges accurate', () => { + const code = 'x <- 1\n\ny <- 2'; + const result = codeFragmentsFromBoundaries(code, [ + boundary(0, 1, 'complete'), + boundary(1, 2, 'whitespace'), + boundary(2, 3, 'complete'), + ]); + expect(result.fragments).toEqual([ + { code: 'x <- 1', startLine: 0, endLine: 1 }, + { code: 'y <- 2', startLine: 2, endLine: 3 }, + ]); + }); + + it('flags an incomplete trailing boundary and does not emit a fragment for it', () => { + const code = 'x <- 1\nf <- function('; + const result = codeFragmentsFromBoundaries(code, [ + boundary(0, 1, 'complete'), + boundary(1, 2, 'incomplete'), + ]); + expect(result.incomplete).toBe(true); + expect(result.invalid).toBe(false); + expect(result.fragments).toEqual([{ code: 'x <- 1', startLine: 0, endLine: 1 }]); + }); + + it('flags an invalid trailing boundary', () => { + const code = 'x <- 1\n)('; + const result = codeFragmentsFromBoundaries(code, [ + boundary(0, 1, 'complete'), + boundary(1, 2, 'invalid'), + ]); + expect(result.invalid).toBe(true); + expect(result.incomplete).toBe(false); + expect(result.fragments).toEqual([{ code: 'x <- 1', startLine: 0, endLine: 1 }]); + }); + + it('returns no fragments for all-whitespace input', () => { + const code = '\n\n'; + const result = codeFragmentsFromBoundaries(code, [boundary(0, 3, 'whitespace')]); + expect(result).toEqual({ fragments: [], incomplete: false, invalid: false }); + }); + + it('throws on non-contiguous ranges', () => { + const code = 'x <- 1\ny <- 2'; + expect(() => codeFragmentsFromBoundaries(code, [ + boundary(0, 1, 'complete'), + // Gap: starts at 2 instead of 1. + boundary(2, 2, 'complete'), + ])).toThrow(); + }); + + it('throws when the ranges do not cover the whole code', () => { + const code = 'x <- 1\ny <- 2'; + expect(() => codeFragmentsFromBoundaries(code, [ + // Only covers the first line. + boundary(0, 1, 'complete'), + ])).toThrow(); + }); + + it('throws when a range extends past the last line', () => { + const code = 'x <- 1'; + expect(() => codeFragmentsFromBoundaries(code, [boundary(0, 2, 'complete')])).toThrow(); + }); + + it('throws on an unknown boundary kind', () => { + const code = 'x <- 1'; + expect(() => codeFragmentsFromBoundaries(code, [ + boundary(0, 1, 'bogus' as IInputBoundary['kind']), + ])).toThrow(); + }); +}); diff --git a/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts b/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts index 49eb8eeda7a0..85efaee4f7ff 100644 --- a/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts +++ b/src/vs/workbench/api/browser/positron/mainThreadLanguageRuntime.ts @@ -13,7 +13,7 @@ import { } from '../../common/positron/extHost.positron.protocol.js'; import { extHostNamedCustomer, IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js'; import { IHostedLanguageContribution, ILanguageRuntimeClientCreatedEvent, ILanguageRuntimeInfo, ILanguageRuntimeMessage, ILanguageRuntimeMessageCommClosed, ILanguageRuntimeMessageCommData, ILanguageRuntimeMessageCommOpen, ILanguageRuntimeMessageError, ILanguageRuntimeMessageInput, ILanguageRuntimeMessageOutput, ILanguageRuntimeMessagePrompt, ILanguageRuntimeMessageState, ILanguageRuntimeMessageStream, ILanguageRuntimeMetadata, ILanguageRuntimeSessionState as ILanguageRuntimeSessionState, ILanguageRuntimeService, ILanguageRuntimeStartupFailure, LanguageRuntimeMessageType, RuntimeBusyBehavior, RuntimeCodeExecutionMode, RuntimeCodeFragmentStatus, RuntimeErrorBehavior, RuntimeState, ILanguageRuntimeExit, RuntimeOutputKind, RuntimeExitReason, ILanguageRuntimeMessageWebOutput, PositronOutputLocation, LanguageRuntimeSessionMode, ILanguageRuntimeMessageResult, ILanguageRuntimeMessageClearOutput, ILanguageRuntimeMessageIPyWidget, IRuntimeManager, IRuntimeRootSignature, ILanguageRuntimeMessageUpdateOutput, ILanguageRuntimeResourceUsage, ILanguageRuntimeLaunchInfo } from '../../../services/languageRuntime/common/languageRuntimeService.js'; -import { ILanguageRuntimePackage, ILanguageRuntimePackageManager, ILanguageRuntimeSession, ILanguageRuntimeSessionManager, IPackageSpec, IRuntimeMissingPackage, IRuntimeMissingPackagesTarget, IRuntimeSessionMetadata, IRuntimeSessionService, RuntimeStartMode } from '../../../services/runtimeSession/common/runtimeSessionService.js'; +import { ILanguageRuntimePackage, ILanguageRuntimePackageManager, ILanguageRuntimeSession, ILanguageRuntimeSessionManager, IPackageSpec, IRuntimeExecutionStatistics, IRuntimeMissingPackage, IRuntimeMissingPackagesTarget, IRuntimeSessionMetadata, IRuntimeSessionService, RuntimeStartMode } from '../../../services/runtimeSession/common/runtimeSessionService.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { Event, Emitter } from '../../../../base/common/event.js'; import { IPositronConsoleService } from '../../../services/positronConsole/browser/interfaces/positronConsoleService.js'; @@ -240,6 +240,24 @@ class ExtHostLanguageRuntimeSessionAdapter extends Disposable implements ILangua */ private readonly _executionCodeLocations = new Map(); + /** + * A bounded map of execution id to the timestamp (ms since epoch) at which + * the execution was submitted to the runtime. Used to measure the latency + * between submitting an execution and receiving its input echo back on + * iopub. Entries are removed once the echo arrives; the map is bounded so a + * missing echo can't cause unbounded growth. + */ + private readonly _pendingExecutionTimes = new Map(); + + /** The number of executions submitted to the runtime. */ + private _executionCount = 0; + + /** The running total of measured input-echo latencies, in milliseconds. */ + private _totalInputLatencyMs = 0; + + /** The number of input-echo latency samples collected. */ + private _inputLatencySamples = 0; + /** Lamport clock, used for event ordering */ private _eventClock = 0; @@ -451,6 +469,14 @@ class ExtHostLanguageRuntimeSessionAdapter extends Disposable implements ILangua } emitDidReceiveRuntimeMessageInput(languageRuntimeMessageInput: ILanguageRuntimeMessageInput) { + // If this input echoes an execution we submitted, record the round-trip + // latency between submission and echo for diagnostics. + const submittedAt = this._pendingExecutionTimes.get(languageRuntimeMessageInput.parent_id); + if (submittedAt !== undefined) { + this._pendingExecutionTimes.delete(languageRuntimeMessageInput.parent_id); + this._totalInputLatencyMs += Date.now() - submittedAt; + this._inputLatencySamples++; + } this._onDidReceiveRuntimeMessageInputEmitter.fire(languageRuntimeMessageInput); } @@ -614,9 +640,21 @@ class ExtHostLanguageRuntimeSessionAdapter extends Disposable implements ILangua errorBehavior: RuntimeErrorBehavior, attribution?: IConsoleCodeAttribution, executionMetadata?: Record, - ): void { + ): Promise { this._lastUsed = Date.now(); + // Track this execution for diagnostics: count it, and remember when it + // was submitted so we can measure the latency until its input echo + // arrives on iopub (see emitDidReceiveRuntimeMessageInput). + this._executionCount++; + this._pendingExecutionTimes.set(id, this._lastUsed); + if (this._pendingExecutionTimes.size > MAX_EXECUTION_CODE_LOCATIONS) { + const oldest = this._pendingExecutionTimes.keys().next().value; + if (oldest !== undefined) { + this._pendingExecutionTimes.delete(oldest); + } + } + let codeLocation: ICodeLocation | undefined = undefined; if (attribution?.source === CodeAttributionSource.Script || @@ -636,13 +674,22 @@ class ExtHostLanguageRuntimeSessionAdapter extends Disposable implements ILangua } } - this._proxy.$executeCode(this.handle, code, id, mode, errorBehavior, codeLocation, undefined, executionMetadata); + return this._proxy.$executeCode(this.handle, code, id, mode, errorBehavior, codeLocation, undefined, executionMetadata); } getExecutionCodeLocation(executionId: string): ICodeLocation | undefined { return this._executionCodeLocations.get(executionId); } + getExecutionStatistics(): IRuntimeExecutionStatistics { + return { + executionCount: this._executionCount, + averageInputLatencyMs: this._inputLatencySamples > 0 + ? this._totalInputLatencyMs / this._inputLatencySamples + : undefined, + }; + } + isCodeFragmentComplete(code: string): Thenable { return this._proxy.$isCodeFragmentComplete(this.handle, code); } diff --git a/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts b/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts index ab9f0967c779..9362b895cc84 100644 --- a/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts +++ b/src/vs/workbench/api/common/positron/extHost.positron.protocol.ts @@ -116,7 +116,7 @@ export interface ExtHostLanguageRuntimeShape { $disposeLanguageRuntime(handle: number): Promise; $startLanguageRuntime(handle: number): Promise; $openResource(handle: number, resource: URI | string): Promise; - $executeCode(handle: number, code: string, id: string, mode: RuntimeCodeExecutionMode, errorBehavior: RuntimeErrorBehavior, codeLocation?: ICodeLocation, executionId?: string, executionMetadata?: Record): void; + $executeCode(handle: number, code: string, id: string, mode: RuntimeCodeExecutionMode, errorBehavior: RuntimeErrorBehavior, codeLocation?: ICodeLocation, executionId?: string, executionMetadata?: Record): Promise; $isCodeFragmentComplete(handle: number, code: string): Promise; $createClient(handle: number, id: string, type: RuntimeClientType, params: unknown, metadata?: unknown): Promise; $listClients(handle: number, type?: RuntimeClientType): Promise>; diff --git a/src/vs/workbench/api/common/positron/extHostLanguageRuntime.ts b/src/vs/workbench/api/common/positron/extHostLanguageRuntime.ts index 7c5ba8f51c0d..2fc90e88a81e 100644 --- a/src/vs/workbench/api/common/positron/extHostLanguageRuntime.ts +++ b/src/vs/workbench/api/common/positron/extHostLanguageRuntime.ts @@ -1003,7 +1003,7 @@ export class ExtHostLanguageRuntime implements extHostProtocol.ExtHostLanguageRu return Promise.resolve(this._runtimeSessions[handle].openResource!(resource)); } - $executeCode(handle: number, code: string, id: string, mode: RuntimeCodeExecutionMode, errorBehavior: RuntimeErrorBehavior, codeLocation?: ICodeLocation, _executionId?: string, executionMetadata?: Record): void { + $executeCode(handle: number, code: string, id: string, mode: RuntimeCodeExecutionMode, errorBehavior: RuntimeErrorBehavior, codeLocation?: ICodeLocation, _executionId?: string, executionMetadata?: Record): Promise { if (handle >= this._runtimeSessions.length) { throw new Error(`Cannot execute code: session handle '${handle}' not found or no longer valid.`); } @@ -1017,7 +1017,10 @@ export class ExtHostLanguageRuntime implements extHostProtocol.ExtHostLanguageRu }; } - this._runtimeSessions[handle].execute(code, id, mode, errorBehavior, codeLocationRevived, executionMetadata); + // Wrap in Promise.resolve so both void- and thenable-returning session + // implementations work, and so a thrown/rejected error propagates back + // over RPC (preserving error.name for CodeIncompleteError etc.). + return Promise.resolve(this._runtimeSessions[handle].execute(code, id, mode, errorBehavior, codeLocationRevived, executionMetadata)); } $isCodeFragmentComplete(handle: number, code: string): Promise { diff --git a/src/vs/workbench/api/common/positron/extHostTypes.positron.ts b/src/vs/workbench/api/common/positron/extHostTypes.positron.ts index 433e4a25dc84..6c28b5d09cf2 100644 --- a/src/vs/workbench/api/common/positron/extHostTypes.positron.ts +++ b/src/vs/workbench/api/common/positron/extHostTypes.positron.ts @@ -269,7 +269,13 @@ export enum RuntimeCodeExecutionMode { * Combined with pending code: No * Stored in history: No */ - Silent = 'silent' + Silent = 'silent', + + /** + * Unprocessed code execution: behaves like `Interactive`, but the code has + * not been checked for completeness; the session checks it before executing. + */ + Unprocessed = 'unprocessed' } /** diff --git a/src/vs/workbench/contrib/languageRuntime/browser/languageRuntimeActions.ts b/src/vs/workbench/contrib/languageRuntime/browser/languageRuntimeActions.ts index 518c374e8363..7d899c0d7919 100644 --- a/src/vs/workbench/contrib/languageRuntime/browser/languageRuntimeActions.ts +++ b/src/vs/workbench/contrib/languageRuntime/browser/languageRuntimeActions.ts @@ -35,6 +35,7 @@ import { IWorkbenchEnvironmentService } from '../../../services/environment/comm import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { getErrorMessage } from '../../../../base/common/errors.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; // The category for language runtime actions. const category: ILocalizedString = { value: LANGUAGE_RUNTIME_ACTION_CATEGORY, original: 'Interpreter' }; @@ -1525,9 +1526,10 @@ export function registerLanguageRuntimeActions() { if (session) { // We already have a console session for the language, so // execute the code in it (silently) - session.execute(args.code, `silent-command-${ExecuteSilentlyAction._counter++}`, + Promise.resolve(session.execute(args.code, `silent-command-${ExecuteSilentlyAction._counter++}`, RuntimeCodeExecutionMode.Silent, - RuntimeErrorBehavior.Continue); + RuntimeErrorBehavior.Continue)).catch((err) => + accessor.get(ILogService).error(`Failed to execute silent command: ${err}`)); } else { // No console session available. Since the intent is usually to // execute the task in the background, notify the user that diff --git a/src/vs/workbench/contrib/positronConsole/browser/components/actionBar.tsx b/src/vs/workbench/contrib/positronConsole/browser/components/actionBar.tsx index bd92fbf55463..0c56b06616a0 100644 --- a/src/vs/workbench/contrib/positronConsole/browser/components/actionBar.tsx +++ b/src/vs/workbench/contrib/positronConsole/browser/components/actionBar.tsx @@ -113,6 +113,9 @@ export const ActionBar = (props: ActionBarProps) => { // Hooks to track when the console can be interrupted and when the interrupt is in progress. const [interruptible, setInterruptible] = useState(false); const [interrupting, setInterrupting] = useState(false); + // Hook to track when a code submission (completeness check) is in progress; + // the interrupt (stop) button is shown so the user can cancel it. + const [submitting, setSubmitting] = useState(false); // Hook to track when the console can be shutdown and restarted // since a restart requires the session kernel to be shutdown. const [canShutdown, setCanShutdown] = useState(false); @@ -321,6 +324,14 @@ export const ActionBar = (props: ActionBarProps) => { // Register for runtime changes. disposableConsoleStore.add(activePositronConsoleInstance.onDidAttachSession(attachRuntime)); + + // Track code-submission progress so the stop button is shown (and + // can cancel the submission) while a completeness check is in flight. + setSubmitting(activePositronConsoleInstance.codeSubmissionInProgress); + disposableConsoleStore.add( + activePositronConsoleInstance.onDidChangeCodeSubmissionInProgress(setSubmitting)); + } else { + setSubmitting(false); } // Return the cleanup function that will dispose of the disposables. @@ -332,8 +343,12 @@ export const ActionBar = (props: ActionBarProps) => { // Interrupt handler. const interruptHandler = async () => { - // Set the interrupting flag to debounch the button. - setInterrupting(true); + // Set the interrupting flag to debounce the button, but not for a + // submission cancel: that resolves quickly and the button should stay + // responsive (the interrupt routes to cancelCodeSubmission). + if (!submitting) { + setInterrupting(true); + } // Interrupt the active Positron console instance. activePositronConsoleInstance?.interrupt(); @@ -428,8 +443,9 @@ export const ActionBar = (props: ActionBarProps) => { }); } - // Interrupt action. - if (interruptible) { + // Interrupt action. Shown while the runtime is busy or while a code + // submission is being prepared (so the user can cancel the submission). + if (interruptible || submitting) { rightActions.push({ fixedWidth: DEFAULT_ACTION_BAR_BUTTON_WIDTH, separator: false, diff --git a/src/vs/workbench/contrib/positronConsole/browser/components/consoleInput.tsx b/src/vs/workbench/contrib/positronConsole/browser/components/consoleInput.tsx index 246d18007a3b..72aae1fc3d00 100644 --- a/src/vs/workbench/contrib/positronConsole/browser/components/consoleInput.tsx +++ b/src/vs/workbench/contrib/positronConsole/browser/components/consoleInput.tsx @@ -7,10 +7,11 @@ import './consoleInput.css'; // React. -import { FocusEvent, useEffect, useLayoutEffect, useRef } from 'react'; +import { FocusEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'; // Other dependencies. import * as DOM from '../../../../../base/browser/dom.js'; +import { ConsoleInputSubmitting } from './consoleInputSubmitting.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { isMacintosh } from '../../../../../base/common/platform.js'; import { HistoryNavigator2 } from '../../../../../base/common/history.js'; @@ -34,17 +35,16 @@ import { TabCompletionController } from '../../../snippets/browser/tabCompletion import { TerminalContextKeys } from '../../../terminal/common/terminalContextKey.js'; import { ParameterHintsController } from '../../../../../editor/contrib/parameterHints/browser/parameterHints.js'; import { SelectionClipboardContributionID } from '../../../codeEditor/browser/selectionClipboard.js'; -import { LanguageRuntimeSessionMode, RuntimeCodeExecutionMode, RuntimeCodeFragmentStatus } from '../../../../services/languageRuntime/common/languageRuntimeService.js'; +import { LanguageRuntimeSessionMode, RuntimeCodeExecutionMode } from '../../../../services/languageRuntime/common/languageRuntimeService.js'; import { PositronConsoleInputCursorBoundary } from '../../../../common/contextkeys.js'; import { HistoryBrowserPopup } from './historyBrowserPopup.js'; import { HistoryInfixMatchStrategy } from '../../common/historyInfixMatchStrategy.js'; import { HistoryPrefixMatchStrategy } from '../../common/historyPrefixMatchStrategy.js'; import { EmptyHistoryMatchStrategy, HistoryMatch, HistoryMatchStrategy } from '../../common/historyMatchStrategy.js'; -import { IPositronConsoleInstance, PositronConsoleState } from '../../../../services/positronConsole/browser/interfaces/positronConsoleService.js'; +import { CodeSubmissionResult, IPositronConsoleInstance, PositronConsoleState } from '../../../../services/positronConsole/browser/interfaces/positronConsoleService.js'; import { ContentHoverController } from '../../../../../editor/contrib/hover/browser/contentHoverController.js'; import { IInputHistoryEntry } from '../../../../services/positronHistory/common/executionHistoryService.js'; import { CodeAttributionSource, IConsoleCodeAttribution } from '../../../../services/positronConsole/common/positronConsoleCodeExecution.js'; -import { localize } from '../../../../../nls.js'; import { createConsoleInputEditorOptions, createConsoleInputLineNumbersOptions, ILineNumbersOptions } from './consoleInputOptions.js'; import { createConsoleInputModel } from './consoleInputModel.js'; import { usePositronReactServicesContext } from '../../../../../base/browser/positronReactRendererContext.js'; @@ -93,6 +93,11 @@ export const ConsoleInput = (props: ConsoleInputProps) => { useStateRef(undefined); const shouldExecuteOnStartRef = useRef(false); + // Whether the debounced submission visuals (dim + green barber pole) should + // be shown. Set 400ms after a submission starts, so quick checks don't + // flicker; cleared as soon as the submission finishes. + const [showSubmittingVisuals, setShowSubmittingVisuals] = useState(false); + /** * Gets the appropriate history navigator based on whether a debug session * with toolbar is active. Uses the debug navigator when a session is active @@ -181,58 +186,39 @@ export const ConsoleInput = (props: ConsoleInputProps) => { return false; } - // Determine whether the code is complete, incomplete, invalid, or - // unknown. We handle errors here since callers don't handle them. - let status = RuntimeCodeFragmentStatus.Unknown; - try { - status = await session.isCodeFragmentComplete(code); - } catch (err) { - if (err instanceof Error) { - services.notificationService.error( - localize('positronConsole.incompleteError', 'Cannot execute code: {0} ({1})', err.name, err.message) - ); - } else { - services.notificationService.error( - localize('positronConsole.incompleteUnknownError', 'Cannot execute code: {0}', JSON.stringify(err)) - ); - } - return false; - } - - switch (status) { - // If the code fragment is complete, execute it. - case RuntimeCodeFragmentStatus.Complete: - break; + const attribution: IConsoleCodeAttribution = { + source: CodeAttributionSource.Interactive + }; - // If the code fragment is incomplete, don't do anything. The user will just see a new - // line in the input area. - case RuntimeCodeFragmentStatus.Incomplete: { - // Don't execute the code, let the code editor widget handle the key event. - return false; - } + // Clear the input immediately so that any keystrokes typed while this + // submission is in flight (type-ahead -- e.g. a debugger command entered + // right after) land in a clean editor rather than being appended to the + // code being submitted or dropped. The submitted code is echoed into the + // transcript when it executes; if the submission turns out incomplete or + // cancelled it is restored below so the user can finish editing it. + setCurrentCodeFragment(undefined); + codeEditorWidgetRef.current.setValue(''); - // If the code fragment is invalid (contains syntax errors), log a warning but execute - // it anyway (so the user can see a syntax error from the interpreter). - case RuntimeCodeFragmentStatus.Invalid: - services.logService.warn( - `Executing invalid code fragment: '${code}'` - ); - break; + const result = await props.positronConsoleInstance.submitCode(code, attribution); - // If the code fragment status is unknown, log a warning but execute it anyway (so the - // user can see an error from the interpreter). - case RuntimeCodeFragmentStatus.Unknown: - services.logService.warn( - `Could not determine whether code fragment: '${code}' is complete.` - ); - break; + // Incomplete: restore the code and let the Enter handler insert a + // continuation line. + if (result === CodeSubmissionResult.Incomplete) { + codeEditorWidgetRef.current.setValue(code); + updateCodeEditorWidgetPosition(Position.Last, Position.Last); + return false; } - // Clear the current code fragment. - setCurrentCodeFragment(undefined); + // Cancelled: restore the code, leaving it editable, and don't insert a + // newline. + if (result === CodeSubmissionResult.Cancelled) { + codeEditorWidgetRef.current.setValue(code); + updateCodeEditorWidgetPosition(Position.Last, Position.Last); + return true; + } - // Clear the code editor widget's model. - codeEditorWidgetRef.current.setValue(''); + // Executed: the input was already cleared above (any type-ahead entered + // since is preserved). Prepare for the next prompt. // Immediately change the prompt to be spaces to eliminate prompt flickering. const promptWidth = Math.max( @@ -244,12 +230,6 @@ export const ConsoleInput = (props: ConsoleInputProps) => { lineNumbersMinChars: promptWidth }); - // Execute the code. - const attribution: IConsoleCodeAttribution = { - source: CodeAttributionSource.Interactive - }; - props.positronConsoleInstance.executeCode(code, attribution); - // Render the code editor widget. codeEditorWidgetRef.current.render(true); @@ -977,6 +957,14 @@ export const ConsoleInput = (props: ConsoleInputProps) => { disposableStore.add(props.positronConsoleInstance.onDidSetPendingCode(pendingCode => { codeEditorWidget.setValue(pendingCode || ''); updateCodeEditorWidgetPosition(Position.Last, Position.Last); + + // If the input was cleared while a submission is in flight, the + // submitting code was promoted into the transcript; hide the + // input-line submitting visuals so the barber pole doesn't linger + // over an empty input (the transcript item carries it instead). + if (!pendingCode) { + setShowSubmittingVisuals(false); + } })); // Add the onDidExecuteCode event handler. @@ -1058,6 +1046,42 @@ export const ConsoleInput = (props: ConsoleInputProps) => { } }, [codeEditorWidgetRef, props.width, setCodeEditorWidth]); + // Debounce the submission visuals: show the dim + barber pole 400ms after a + // submission starts (and only if it is still in progress), and hide them as + // soon as the submission finishes. + useEffect(() => { + const instance = props.positronConsoleInstance; + let timer: ReturnType | undefined; + const disposable = instance.onDidChangeCodeSubmissionInProgress(inProgress => { + if (inProgress) { + timer = setTimeout(() => { + timer = undefined; + // Only show the input-line submitting visuals if there is + // code in the input. When code is promoted into the + // transcript (because more code was queued behind it), the + // input is empty and the barber pole rides the transcript + // item instead. + if (instance.codeSubmissionInProgress && + codeEditorWidgetRef.current.getValue().length > 0) { + setShowSubmittingVisuals(true); + } + }, 400); + } else { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + setShowSubmittingVisuals(false); + } + }); + return () => { + if (timer) { + clearTimeout(timer); + } + disposable.dispose(); + }; + }, [props.positronConsoleInstance, codeEditorWidgetRef]); + /** * onFocus event handler. * @param e A FocusEvent that contains the event data. @@ -1097,8 +1121,13 @@ export const ConsoleInput = (props: ConsoleInputProps) => { } // Render. + const consoleInputClassName = + 'console-input' + + (props.hidden ? ' hidden' : '') + + (showSubmittingVisuals ? ' submitting' : ''); return ( -
+
+
{historyBrowserActive && ('console'), // IEditorOptions we override from their configured values. ...{ - readOnly: false, + readOnly, minimap: { enabled: false }, diff --git a/src/vs/workbench/contrib/positronConsole/browser/components/consoleInputSubmitting.css b/src/vs/workbench/contrib/positronConsole/browser/components/consoleInputSubmitting.css new file mode 100644 index 000000000000..e2a9bb830599 --- /dev/null +++ b/src/vs/workbench/contrib/positronConsole/browser/components/consoleInputSubmitting.css @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Dim the input's editor while a submission is in flight. The transition lives + * on the `.submitting` rule so it eases the dimming in over 500ms rather than + * snapping on; when the class is removed the transition goes with it, so the + * editor snaps back to full opacity at once (matching the barber pole, which + * unmounts instantly). + */ +.console-input.submitting .monaco-editor { + opacity: 0.7; + transition: opacity 500ms ease; +} + +/* + * While a submission is in flight the input is read-only, so its cursor stops + * blinking and sits frozen in the "on" state, which makes the input look hung. + * Hide the cursor for the duration so the barber pole is the only motion. The + * `!important` is required because Monaco toggles the cursor's `display` with an + * inline style (see viewCursor.ts), which a plain rule can't override. + */ +.console-input.submitting .monaco-editor .cursors-layer > .cursor { + display: none !important; +} + +/* + * A green barber pole in the console's 10px left gutter, signalling that the + * submitted code is being prepared for execution. Mirrors the blue + * `.runtime-starting .left-bar` barber pole; see runtimeStarting.css. + * + * The console instance reserves a 10px left gutter via `padding-left` (see + * consoleInstance.css). `left: -10px` reaches out of the input's content box + * into that gutter so the bar sits to the left of the editor's own prompt + * margin rather than behind it (which left it partially occluded and only + * visible below the input). `bottom: 10px` matches the input's padding-bottom + * so the bar spans the editor exactly. + */ +.console-input-submitting-bar { + position: absolute; + top: 0; + left: -10px; + bottom: 10px; + width: 4px; + pointer-events: none; + background-image: repeating-linear-gradient(45deg, + var(--vscode-positronConsole-ansiBrightGreen), + var(--vscode-positronConsole-ansiBrightGreen) 3px, + transparent 3px, + transparent 6px); + background-size: 8.49px 8.49px; + /* + * The bar mounts fresh when the submitting visuals become active, so a + * plain transition has no prior state to animate from. Pair the endless + * stripe scroll with a one-shot fade-in so the pole eases in over 500ms + * rather than popping into view. + */ + animation: + console-input-submitting-fade-in 500ms ease-out, + console-input-submitting-move-stripes 1s linear infinite; +} + +@keyframes console-input-submitting-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes console-input-submitting-move-stripes { + 0% { + background-position: 0 0; + } + + 100% { + background-position: 8.49px 0; + } +} diff --git a/src/vs/workbench/contrib/positronConsole/browser/components/consoleInputSubmitting.tsx b/src/vs/workbench/contrib/positronConsole/browser/components/consoleInputSubmitting.tsx new file mode 100644 index 000000000000..ae1e5d7a344d --- /dev/null +++ b/src/vs/workbench/contrib/positronConsole/browser/components/consoleInputSubmitting.tsx @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +// CSS. +import './consoleInputSubmitting.css'; + +/** + * ConsoleInputSubmittingProps interface. + */ +interface ConsoleInputSubmittingProps { + /** Whether the barber pole should be shown. */ + readonly visible: boolean; +} + +/** + * ConsoleInputSubmitting component. Renders a green barber pole over the console + * input's gutter while a code submission is being prepared for execution. The + * parent owns the debounce timer and passes `visible`. + * + * @param props A ConsoleInputSubmittingProps that contains the component props. + * @returns The rendered component. + */ +export const ConsoleInputSubmitting = (props: ConsoleInputSubmittingProps) => { + if (!props.visible) { + return null; + } + return
; +}; diff --git a/src/vs/workbench/contrib/positronConsole/browser/components/consoleInstance.tsx b/src/vs/workbench/contrib/positronConsole/browser/components/consoleInstance.tsx index 8b252bf51675..81e660453804 100644 --- a/src/vs/workbench/contrib/positronConsole/browser/components/consoleInstance.tsx +++ b/src/vs/workbench/contrib/positronConsole/browser/components/consoleInstance.tsx @@ -14,6 +14,7 @@ import { getActiveWindow } from '../../../../../base/browser/dom.js'; import * as nls from '../../../../../nls.js'; import * as DOM from '../../../../../base/browser/dom.js'; import { ConsoleInstanceItems } from './consoleInstanceItems.js'; +import { SubmittingOverlay } from './submittingOverlay.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { disposableTimeout } from '../../../../../base/common/async.js'; import { usePositronConsoleContext } from '../positronConsoleContext.js'; @@ -74,6 +75,11 @@ export const ConsoleInstance = (props: ConsoleInstanceProps) => { const [, setIgnoreNextScrollEvent, ignoreNextScrollEventRef] = useStateRef(false); const [disconnected, setDisconnected] = useState(false); + // Whether the "Submitting..." + Cancel overlay should be shown. Set 1000ms + // after a submission starts (and only if it is still in progress), cleared + // as soon as the submission finishes. + const [showSubmittingOverlay, setShowSubmittingOverlay] = useState(false); + // Attach the find widget DOM node to the console container. useEffect(() => { const domNode = props.positronConsoleInstance.findWidgetDomNode; @@ -103,6 +109,36 @@ export const ConsoleInstance = (props: ConsoleInstanceProps) => { props.positronConsoleInstance.layoutFindWidget(props.width); }, [props.positronConsoleInstance, props.width]); + // Debounce the "Submitting..." + Cancel overlay: show it 1000ms after a + // submission starts (and only if it is still in progress), and hide it as + // soon as the submission finishes. + useEffect(() => { + const instance = props.positronConsoleInstance; + let timer: ReturnType | undefined; + const disposable = instance.onDidChangeCodeSubmissionInProgress(inProgress => { + if (inProgress) { + timer = setTimeout(() => { + timer = undefined; + if (instance.codeSubmissionInProgress) { + setShowSubmittingOverlay(true); + } + }, 1000); + } else { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + setShowSubmittingOverlay(false); + } + }); + return () => { + if (timer) { + clearTimeout(timer); + } + disposable.dispose(); + }; + }, [props.positronConsoleInstance]); + // Anchor to bottom on shrink. The browser auto-clamps scrollTop on grow but not on shrink, so // we need to set it ourselves. ResizeObserver fires between layout and paint, keeping the // correction in the same frame as the size change — a useLayoutEffect would run a frame later @@ -747,6 +783,10 @@ export const ConsoleInstance = (props: ConsoleInstanceProps) => { onSelectAll={() => selectAllRuntimeItems()} />
+ props.positronConsoleInstance.cancelCodeSubmission()} + />
); }; diff --git a/src/vs/workbench/contrib/positronConsole/browser/components/consoleInstanceItems.tsx b/src/vs/workbench/contrib/positronConsole/browser/components/consoleInstanceItems.tsx index 42bd783fe997..dbde37751d62 100644 --- a/src/vs/workbench/contrib/positronConsole/browser/components/consoleInstanceItems.tsx +++ b/src/vs/workbench/contrib/positronConsole/browser/components/consoleInstanceItems.tsx @@ -168,7 +168,9 @@ export class ConsoleInstanceItems extends Component {
}