From 7511f058be678e68f6185a45a6cc4a589d362e09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:25:16 +0000 Subject: [PATCH 1/4] Initial plan From 1112486b9818bfed6bb7111d4650790a49307a1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:42:00 +0000 Subject: [PATCH 2/4] Show the command line in terminal sticky scroll when the prompt does not fit Co-authored-by: anthonykim1 <62267334+anthonykim1@users.noreply.github.com> --- .../browser/terminalStickyScrollOverlay.ts | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts index 01ce5480db2bb..23823fc0b66fb 100644 --- a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts @@ -279,7 +279,6 @@ export class TerminalStickyScrollOverlay extends Disposable { const buffer = xterm.buffer.active; const promptRowCount = command.getPromptRowCount(); const commandRowCount = command.getCommandRowCount(); - const stickyScrollLineStart = startMarker.line - (promptRowCount - 1); // Calculate the row offset, this is the number of rows that will be clipped from the top // of the sticky overlay because we do not want to show any content above the bounds of the @@ -288,8 +287,7 @@ export class TerminalStickyScrollOverlay extends Disposable { const isPartialCommand = !isFullTerminalCommand(command); const rowOffset = !isPartialCommand && command.endMarker ? Math.max(buffer.viewportY - command.endMarker.line + 1, 0) : 0; const maxLineCount = Math.min(this._rawMaxLineCount, Math.floor(xterm.rows * Constants.StickyScrollPercentageCap)); - const stickyScrollLineCount = Math.min(promptRowCount + commandRowCount - 1, maxLineCount) - rowOffset; - const isTruncated = stickyScrollLineCount < promptRowCount + commandRowCount - 1; + const { lineStart: stickyScrollLineStart, lineCount: stickyScrollLineCount, isTruncated } = getStickyScrollLayout(startMarker.line, promptRowCount, commandRowCount, maxLineCount, rowOffset); // Hide sticky scroll if it's currently on a line that contains it if (buffer.viewportY <= stickyScrollLineStart) { @@ -554,3 +552,38 @@ function lineStartsWith(line: IBufferLine | undefined, text: string): boolean { } return true; } + +/** + * Determines what part of a command's prompt and command line to show in the sticky scroll overlay. + * + * The command line is prioritized over the prompt, only including the prompt when the prompt and + * the command line both fit within `maxLineCount`. This matters because the rows above the command + * line are not necessarily prompt rows, for example output printed in between the previous command + * finishing and the prompt being drawn (job control notifications, background processes, etc.) is + * considered part of the prompt. Including those rows regardless would push the command line out of + * the overlay, showing only output and hiding the command the output belongs to. + * + * @param commandStartLine The buffer line the command line starts on. + * @param promptRowCount The number of rows the prompt spans, including the command start row. + * @param commandRowCount The number of rows the command line spans, including the command start row. + * @param maxLineCount The maximum number of rows the overlay is allowed to show. + * @param rowOffset The number of rows to clip from the top of the overlay. + */ +export function getStickyScrollLayout( + commandStartLine: number, + promptRowCount: number, + commandRowCount: number, + maxLineCount: number, + rowOffset: number +): { lineStart: number; lineCount: number; isTruncated: boolean } { + // The prompt and the command line share the command start row + const promptAndCommandRowCount = promptRowCount + commandRowCount - 1; + const includePrompt = promptAndCommandRowCount <= maxLineCount; + const contentRowCount = includePrompt ? promptAndCommandRowCount : commandRowCount; + const lineCount = Math.min(contentRowCount, maxLineCount) - rowOffset; + return { + lineStart: includePrompt ? commandStartLine - (promptRowCount - 1) : commandStartLine, + lineCount, + isTruncated: lineCount < contentRowCount + }; +} From d2b2669cea9f5c3d03bf31ddfbb13c03277eca3c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:45:52 +0000 Subject: [PATCH 3/4] Add tests for terminal sticky scroll layout Co-authored-by: anthonykim1 <62267334+anthonykim1@users.noreply.github.com> --- .../terminalStickyScrollOverlay.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/vs/workbench/contrib/terminalContrib/stickyScroll/test/browser/terminalStickyScrollOverlay.test.ts diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/test/browser/terminalStickyScrollOverlay.test.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/test/browser/terminalStickyScrollOverlay.test.ts new file mode 100644 index 0000000000000..6f14dd4e364d4 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/test/browser/terminalStickyScrollOverlay.test.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Terminal } from '@xterm/xterm'; +import { deepStrictEqual } from 'assert'; +import { importAMDNodeModule } from '../../../../../../amdX.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { CommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/commandDetectionCapability.js'; +import { TestXtermLogger } from '../../../../../../platform/terminal/test/common/terminalTestHelpers.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { writeP } from '../../../../terminal/browser/terminalTestHelpers.js'; +import { getStickyScrollLayout } from '../../browser/terminalStickyScrollOverlay.js'; + +suite('TerminalStickyScrollOverlay', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + suite('getStickyScrollLayout', () => { + test('should show the prompt and the command line when they fit', () => { + deepStrictEqual(getStickyScrollLayout(10, 2, 1, 5, 0), { lineStart: 9, lineCount: 2, isTruncated: false }); + }); + + test('should truncate the command line when it does not fit', () => { + deepStrictEqual(getStickyScrollLayout(10, 1, 8, 5, 0), { lineStart: 10, lineCount: 5, isTruncated: true }); + }); + + test('should drop the prompt when the prompt and the command line do not fit', () => { + deepStrictEqual(getStickyScrollLayout(10, 6, 1, 5, 0), { lineStart: 10, lineCount: 1, isTruncated: false }); + }); + + test('should prefer the command line rows over the prompt rows', () => { + deepStrictEqual(getStickyScrollLayout(10, 2, 5, 5, 0), { lineStart: 10, lineCount: 5, isTruncated: false }); + }); + + test('should clip rows from the top of the overlay for the row offset', () => { + deepStrictEqual(getStickyScrollLayout(10, 3, 1, 5, 1), { lineStart: 8, lineCount: 2, isTruncated: true }); + }); + }); + + suite('command detection', () => { + let xterm: Terminal; + let capability: CommandDetectionCapability; + + setup(async () => { + const TerminalCtor = (await importAMDNodeModule('@xterm/xterm', 'lib/xterm.js')).Terminal; + xterm = store.add(new TerminalCtor({ allowProposedApi: true, cols: 80, rows: 30, logger: TestXtermLogger })); + const instantiationService = workbenchInstantiationService(undefined, store); + capability = store.add(instantiationService.createInstance(CommandDetectionCapability, xterm)); + }); + + test('should show the command line when output is printed after the previous command finished', async () => { + // A command finishes and more output is printed before the prompt is drawn. The prompt + // start is reported at the previous command's end, so that output is treated as part + // of the prompt of the following command. + capability.handlePromptStart(); + await writeP(xterm, '\r$ '); + capability.handleCommandStart(); + await writeP(xterm, 'npm install'); + capability.handleCommandExecuted(); + await writeP(xterm, '\r\nadded 100 packages\r\n'); + capability.handleCommandFinished(0); + await writeP(xterm, 'output printed after the command finished\r\n'.repeat(5)); + + capability.handlePromptStart(); + await writeP(xterm, '$ '); + capability.handleCommandStart(); + await writeP(xterm, './scripts/code.sh'); + capability.handleCommandExecuted(); + + const command = capability.currentCommand!; + const commandStartLine = command.commandStartMarker!.line; + deepStrictEqual( + getStickyScrollLayout(commandStartLine, command.getPromptRowCount(), command.getCommandRowCount(), 5, 0), + { lineStart: commandStartLine, lineCount: 1, isTruncated: false } + ); + }); + }); +}); From c5258da9ae5a4064b1cb86a64afe89f866f323ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:57:09 +0000 Subject: [PATCH 4/4] Hide the sticky scroll overlay when the row offset clips all rows Co-authored-by: anthonykim1 <62267334+anthonykim1@users.noreply.github.com> --- .../browser/terminalStickyScrollOverlay.ts | 10 ++++++---- .../test/browser/terminalStickyScrollOverlay.test.ts | 9 +++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts index 23823fc0b66fb..3e2d08a9a251c 100644 --- a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts @@ -289,8 +289,9 @@ export class TerminalStickyScrollOverlay extends Disposable { const maxLineCount = Math.min(this._rawMaxLineCount, Math.floor(xterm.rows * Constants.StickyScrollPercentageCap)); const { lineStart: stickyScrollLineStart, lineCount: stickyScrollLineCount, isTruncated } = getStickyScrollLayout(startMarker.line, promptRowCount, commandRowCount, maxLineCount, rowOffset); - // Hide sticky scroll if it's currently on a line that contains it - if (buffer.viewportY <= stickyScrollLineStart) { + // Hide sticky scroll if there is nothing to show or if it's currently on a line that + // contains it + if (stickyScrollLineCount <= 0 || buffer.viewportY <= stickyScrollLineStart) { this._setVisible(false); return; } @@ -567,7 +568,8 @@ function lineStartsWith(line: IBufferLine | undefined, text: string): boolean { * @param promptRowCount The number of rows the prompt spans, including the command start row. * @param commandRowCount The number of rows the command line spans, including the command start row. * @param maxLineCount The maximum number of rows the overlay is allowed to show. - * @param rowOffset The number of rows to clip from the top of the overlay. + * @param rowOffset The number of rows to clip from the top of the overlay. A `lineCount` of 0 means + * the row offset clipped all rows and there is nothing to show. */ export function getStickyScrollLayout( commandStartLine: number, @@ -580,7 +582,7 @@ export function getStickyScrollLayout( const promptAndCommandRowCount = promptRowCount + commandRowCount - 1; const includePrompt = promptAndCommandRowCount <= maxLineCount; const contentRowCount = includePrompt ? promptAndCommandRowCount : commandRowCount; - const lineCount = Math.min(contentRowCount, maxLineCount) - rowOffset; + const lineCount = Math.max(Math.min(contentRowCount, maxLineCount) - rowOffset, 0); return { lineStart: includePrompt ? commandStartLine - (promptRowCount - 1) : commandStartLine, lineCount, diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/test/browser/terminalStickyScrollOverlay.test.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/test/browser/terminalStickyScrollOverlay.test.ts index 6f14dd4e364d4..7ade430a01004 100644 --- a/src/vs/workbench/contrib/terminalContrib/stickyScroll/test/browser/terminalStickyScrollOverlay.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/test/browser/terminalStickyScrollOverlay.test.ts @@ -36,6 +36,10 @@ suite('TerminalStickyScrollOverlay', () => { test('should clip rows from the top of the overlay for the row offset', () => { deepStrictEqual(getStickyScrollLayout(10, 3, 1, 5, 1), { lineStart: 8, lineCount: 2, isTruncated: true }); }); + + test('should show nothing when the row offset clips all rows', () => { + deepStrictEqual(getStickyScrollLayout(10, 6, 1, 5, 1), { lineStart: 10, lineCount: 0, isTruncated: true }); + }); }); suite('command detection', () => { @@ -70,9 +74,10 @@ suite('TerminalStickyScrollOverlay', () => { const command = capability.currentCommand!; const commandStartLine = command.commandStartMarker!.line; + const promptRowCount = command.getPromptRowCount(); deepStrictEqual( - getStickyScrollLayout(commandStartLine, command.getPromptRowCount(), command.getCommandRowCount(), 5, 0), - { lineStart: commandStartLine, lineCount: 1, isTruncated: false } + { promptRowCount, ...getStickyScrollLayout(commandStartLine, promptRowCount, command.getCommandRowCount(), 5, 0) }, + { promptRowCount: 6, lineStart: commandStartLine, lineCount: 1, isTruncated: false } ); }); });