diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index d8176438e45c1..7cbc201021256 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -20,7 +20,7 @@ import '../media/chatTerminalToolProgressPart.css'; import type { ICodeBlockRenderOptions } from '../codeBlockPart.js'; import { Action, IAction } from '../../../../../../../base/common/actions.js'; import { IChatTerminalToolProgressPart, ITerminalChatService, ITerminalConfigurationService, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../../../../terminal/browser/terminal.js'; -import { Disposable, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../../base/common/lifecycle.js'; import { Emitter } from '../../../../../../../base/common/event.js'; import { ThemeIcon } from '../../../../../../../base/common/themables.js'; import { DecorationSelector, getTerminalCommandDecorationState, getTerminalCommandDecorationTooltip } from '../../../../../terminal/browser/xterm/decorationStyles.js'; @@ -213,6 +213,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart private readonly _isSerializedInvocation: boolean; private _terminalInstance: ITerminalInstance | undefined; private readonly _decoration: TerminalCommandDecoration; + private _autoExpandTimeout: ReturnType | undefined; + private _userToggledOutput: boolean = false; private markdownPart: ChatMarkdownContentPart | undefined; public get codeblocks(): IChatCodeBlockInfo[] { @@ -506,16 +508,51 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart return; } - commandDetectionListener.value = commandDetection.onCommandFinished(() => { + const store = new DisposableStore(); + store.add(commandDetection.onCommandExecuted(() => { + this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); + // Auto-expand if there's output, checking periodically for up to 1 second + if (!this._outputView.isExpanded && !this._userToggledOutput && !this._autoExpandTimeout) { + let attempts = 0; + const maxAttempts = 5; + const checkForOutput = () => { + this._autoExpandTimeout = undefined; + if (this._store.isDisposed || this._outputView.isExpanded || this._userToggledOutput) { + return; + } + if (this._hasOutput(terminalInstance)) { + this._toggleOutput(true); + return; + } + attempts++; + if (attempts < maxAttempts) { + this._autoExpandTimeout = setTimeout(checkForOutput, 200); + } + }; + this._autoExpandTimeout = setTimeout(checkForOutput, 200); + } + })); + store.add(commandDetection.onCommandFinished(() => { this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); const resolvedCommand = this._getResolvedCommand(terminalInstance); + + // Auto-collapse on success + if (resolvedCommand?.exitCode === 0 && this._outputView.isExpanded && !this._userToggledOutput) { + this._toggleOutput(false); + } if (resolvedCommand?.endMarker) { commandDetectionListener.clear(); } - }); + })); + commandDetectionListener.value = store; + const resolvedImmediately = await tryResolveCommand(); if (resolvedImmediately?.endMarker) { commandDetectionListener.clear(); + // Auto-collapse on success + if (resolvedImmediately.exitCode === 0 && this._outputView.isExpanded && !this._userToggledOutput) { + this._toggleOutput(false); + } return; } }; @@ -595,6 +632,10 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } private _handleDispose(): void { + if (this._autoExpandTimeout) { + clearTimeout(this._autoExpandTimeout); + this._autoExpandTimeout = undefined; + } this._terminalOutputContextKey.reset(); this._terminalChatService.clearFocusedProgressPart(this); } @@ -623,6 +664,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } public async toggleOutputFromKeyboard(): Promise { + this._userToggledOutput = true; if (!this._outputView.isExpanded) { await this._toggleOutput(true); this.focusOutput(); @@ -632,6 +674,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } private async _toggleOutputFromAction(): Promise { + this._userToggledOutput = true; if (!this._outputView.isExpanded) { await this._toggleOutput(true); return; @@ -646,17 +689,52 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._focusChatInput(); } + private _hasOutput(terminalInstance: ITerminalInstance): boolean { + // Check for snapshot + if (this._terminalData.terminalCommandOutput?.text?.trim()) { + return true; + } + // Check for live output (cursor moved past executed marker) + const command = this._getResolvedCommand(terminalInstance); + if (!command?.executedMarker || terminalInstance.isDisposed) { + return false; + } + const buffer = terminalInstance.xterm?.raw.buffer.active; + if (!buffer) { + return false; + } + const cursorLine = buffer.baseY + buffer.cursorY; + return cursorLine > command.executedMarker.line; + } + private _resolveCommand(instance: ITerminalInstance): ITerminalCommand | undefined { if (instance.isDisposed) { return undefined; } const commandDetection = instance.capabilities.get(TerminalCapability.CommandDetection); - const commands = commandDetection?.commands; - if (!commands || commands.length === 0) { + if (!commandDetection) { + return undefined; + } + + const targetId = this._terminalData.terminalCommandId; + if (!targetId) { return undefined; } - return commands.find(c => c.id === this._terminalData.terminalCommandId); + const commands = commandDetection.commands; + if (commands && commands.length > 0) { + const fromHistory = commands.find(c => c.id === targetId); + if (fromHistory) { + return fromHistory; + } + } + + const executing = commandDetection.executingCommandObject; + if (executing && executing.id === targetId) { + return executing; + } + + return undefined; } } @@ -670,6 +748,8 @@ class ChatTerminalToolOutputSection extends Disposable { private readonly _outputBody: HTMLElement; private _scrollableContainer: DomScrollableElement | undefined; private _renderedOutputHeight: number | undefined; + private _isAtBottom: boolean = true; + private _isProgrammaticScroll: boolean = false; private _mirror: DetachedTerminalCommandMirror | undefined; private _snapshotMirror: DetachedTerminalSnapshotMirror | undefined; private readonly _contentContainer: HTMLElement; @@ -738,6 +818,7 @@ class ChatTerminalToolOutputSection extends Disposable { if (!expanded) { this._renderedOutputHeight = undefined; + this._isAtBottom = true; this._onDidChangeHeight(); return true; } @@ -825,6 +906,14 @@ class ChatTerminalToolOutputSection extends Disposable { scrollableDomNode.tabIndex = 0; this.domNode.appendChild(scrollableDomNode); this.updateAriaLabel(); + + // Track scroll state to enable scroll lock behavior (only for user scrolls) + this._register(this._scrollableContainer.onScroll(() => { + if (this._isProgrammaticScroll) { + return; + } + this._isAtBottom = this._computeIsAtBottom(); + })); } private async _updateTerminalContent(): Promise { @@ -858,9 +947,22 @@ class ChatTerminalToolOutputSection extends Disposable { this._disposeLiveMirror(); return false; } - this._mirror = this._register(this._instantiationService.createInstance(DetachedTerminalCommandMirror, liveTerminalInstance.xterm!, command)); - await this._mirror.attach(this._terminalContainer); - const result = await this._mirror.renderCommand(); + const mirror = this._register(this._instantiationService.createInstance(DetachedTerminalCommandMirror, liveTerminalInstance.xterm, command)); + this._mirror = mirror; + this._register(mirror.onDidUpdate(lineCount => { + this._layoutOutput(lineCount); + if (this._isAtBottom) { + this._scrollOutputToBottom(); + } + })); + // Forward input from the mirror terminal to the live terminal instance + this._register(mirror.onDidInput(data => { + if (!liveTerminalInstance.isDisposed) { + liveTerminalInstance.sendText(data, false); + } + })); + await mirror.attach(this._terminalContainer); + const result = await mirror.renderCommand(); if (!result || result.lineCount === 0) { this._showEmptyMessage(localize('chat.terminalOutputEmpty', 'No output was produced by the command.')); } else { @@ -975,12 +1077,25 @@ class ChatTerminalToolOutputSection extends Disposable { } } + private _computeIsAtBottom(): boolean { + if (!this._scrollableContainer) { + return true; + } + const dimensions = this._scrollableContainer.getScrollDimensions(); + const scrollPosition = this._scrollableContainer.getScrollPosition(); + // Consider "at bottom" if within a small threshold to account for rounding + const threshold = 5; + return scrollPosition.scrollTop >= dimensions.scrollHeight - dimensions.height - threshold; + } + private _scrollOutputToBottom(): void { if (!this._scrollableContainer) { return; } + this._isProgrammaticScroll = true; const dimensions = this._scrollableContainer.getScrollDimensions(); this._scrollableContainer.setScrollPosition({ scrollTop: dimensions.scrollHeight }); + this._isProgrammaticScroll = false; } private _getOutputContentHeight(lineCount: number, rowHeight: number, padding: number): number { diff --git a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts index 521da1993cbb6..4af69123f2a2b 100644 --- a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts +++ b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts @@ -3,8 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; -import type { IMarker as IXtermMarker } from '@xterm/xterm'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { CancellationError } from '../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import type { IMarker as IXtermMarker, Terminal as RawXtermTerminal } from '@xterm/xterm'; import type { ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalService, type IDetachedTerminalInstance } from './terminal.js'; import { DetachedProcessInfo } from './detachedTerminal.js'; @@ -17,6 +19,7 @@ import { editorBackground } from '../../../../platform/theme/common/colorRegistr import { Color } from '../../../../base/common/color.js'; import type { IChatTerminalToolInvocationData } from '../../chat/common/chatService/chatService.js'; import type { IColorTheme } from '../../../../platform/theme/common/themeService.js'; +import { ICurrentPartialCommand } from '../../../../platform/terminal/common/capabilities/commandDetection/terminalCommand.js'; function getChatTerminalBackgroundColor(theme: IColorTheme, contextKeyService: IContextKeyService, storedBackground?: string): Color | undefined { if (storedBackground) { @@ -35,41 +38,16 @@ function getChatTerminalBackgroundColor(theme: IColorTheme, contextKeyService: I return theme.getColor(isInEditor ? editorBackground : PANEL_BACKGROUND); } -/** - * Base class for detached terminal mirrors. - * Handles attaching to containers and managing the detached terminal instance. - */ -abstract class DetachedTerminalMirror extends Disposable { - private _detachedTerminal: Promise | undefined; - private _attachedContainer: HTMLElement | undefined; - - protected _setDetachedTerminal(detachedTerminal: Promise): void { - this._detachedTerminal = detachedTerminal.then(terminal => { - if (this._store.isDisposed) { - terminal.dispose(); - return terminal; - } - return this._register(terminal); - }); - } - - protected async _getTerminal(): Promise { - if (!this._detachedTerminal) { - throw new Error('Detached terminal not initialized'); - } - return this._detachedTerminal; - } +interface IDetachedTerminalCommandMirror { + attach(container: HTMLElement): Promise; + renderCommand(): Promise<{ lineCount?: number } | undefined>; + onDidUpdate: Event; + onDidInput: Event; +} - protected async _attachToContainer(container: HTMLElement): Promise { - const terminal = await this._getTerminal(); - container.classList.add('chat-terminal-output-terminal'); - const needsAttach = this._attachedContainer !== container || container.firstChild === null; - if (needsAttach) { - terminal.attachToElement(container, { enableGpu: false }); - this._attachedContainer = container; - } - return terminal; - } +const enum ChatTerminalMirrorMetrics { + MirrorRowCount = 10, + MirrorColCountFallback = 80 } export async function getCommandOutputSnapshot( @@ -115,13 +93,13 @@ export async function getCommandOutputSnapshot( if (!text) { return { text: '', lineCount: 0 }; } - const endLine = endMarker.line - 1; + const endLine = endMarker.line; const lineCount = Math.max(endLine - startLine + 1, 0); return { text, lineCount }; } const startLine = executedMarker.line; - const endLine = endMarker.line - 1; + const endLine = endMarker.line; const lineCount = Math.max(endLine - startLine + 1, 0); let text: string | undefined; @@ -138,53 +116,359 @@ export async function getCommandOutputSnapshot( return { text, lineCount }; } -interface IDetachedTerminalCommandMirror { - attach(container: HTMLElement): Promise; - renderCommand(): Promise<{ lineCount?: number } | undefined>; -} - /** * Mirrors a terminal command's output into a detached terminal instance. - * Used in the chat terminal tool progress part to show command output for example. + * Used in the chat terminal tool progress part to show command output. */ -export class DetachedTerminalCommandMirror extends DetachedTerminalMirror implements IDetachedTerminalCommandMirror { +export class DetachedTerminalCommandMirror extends Disposable implements IDetachedTerminalCommandMirror { + // Streaming approach + // ------------------ + // The mirror maintains a VT snapshot of the command's output and incrementally updates a + // detached xterm instance instead of re-rendering the whole range on every change. + // + // - A *dirty range* is the set of buffer rows that may have diverged between the source + // terminal and the detached mirror. It is tracked by: + // - `_lastUpToDateCursorY`: the last cursor row in the source buffer for which the + // mirror is known to be fully up to date. + // - `_lowestDirtyCursorY`: the smallest (top-most) cursor row that has been affected + // by new data or cursor movement since the last flush. + // + // - When new data arrives or the cursor moves, xterm events and `onData` callbacks are + // used to update `_lowestDirtyCursorY`. This effectively marks everything from that row + // downwards as potentially stale. + // + // - If the dirty range starts exactly at the previous end of the mirrored output (that is, + // `_lowestDirtyCursorY` is at or after `_lastUpToDateCursorY` and no earlier rows have + // changed), the mirror can *append* VT that corresponds only to the new rows. + // + // - If the cursor moves or data is written above the previously mirrored end (for example, + // when the command rewrites lines, uses carriage returns, or modifies earlier rows), + // `_lowestDirtyCursorY` will be before `_lastUpToDateCursorY`. In that case the mirror + // cannot safely append and instead falls back to taking a fresh VT snapshot of the + // entire command range and *rewrites* the detached terminal content. + + private _detachedTerminal: IDetachedTerminalInstance | undefined; + private _detachedTerminalPromise: Promise | undefined; + private _attachedContainer: HTMLElement | undefined; + private readonly _streamingDisposables = this._register(new DisposableStore()); + private readonly _onDidUpdateEmitter = this._register(new Emitter()); + public readonly onDidUpdate: Event = this._onDidUpdateEmitter.event; + private readonly _onDidInputEmitter = this._register(new Emitter()); + public readonly onDidInput: Event = this._onDidInputEmitter.event; + + private _lastVT = ''; + private _lineCount = 0; + private _lastUpToDateCursorY: number | undefined; + private _lowestDirtyCursorY: number | undefined; + private _flushPromise: Promise | undefined; + private _dirtyScheduled = false; + private _isStreaming = false; + private _sourceRaw: RawXtermTerminal | undefined; + constructor( private readonly _xtermTerminal: XtermTerminal, private readonly _command: ITerminalCommand, @ITerminalService private readonly _terminalService: ITerminalService, - @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService ) { super(); - const processInfo = this._register(new DetachedProcessInfo({ initialCwd: '' })); - this._setDetachedTerminal(this._terminalService.createDetachedTerminal({ - cols: this._xtermTerminal.raw!.cols, - rows: 10, - readonly: true, - processInfo, - disableOverviewRuler: true, - colorProvider: { - getBackgroundColor: theme => getChatTerminalBackgroundColor(theme, this._contextKeyService), - }, + this._register(toDisposable(() => { + this._stopStreaming(); })); } async attach(container: HTMLElement): Promise { - await this._attachToContainer(container); + if (this._store.isDisposed) { + return; + } + let terminal: IDetachedTerminalInstance; + try { + terminal = await this._getOrCreateTerminal(); + } catch (error) { + if (error instanceof CancellationError) { + return; + } + throw error; + } + if (this._store.isDisposed) { + return; + } + if (this._attachedContainer !== container) { + container.classList.add('chat-terminal-output-terminal'); + terminal.attachToElement(container, { enableGpu: false }); + this._attachedContainer = container; + } } async renderCommand(): Promise<{ lineCount?: number } | undefined> { - const vt = await getCommandOutputSnapshot(this._xtermTerminal, this._command); + if (this._store.isDisposed) { + return undefined; + } + let detached: IDetachedTerminalInstance; + try { + detached = await this._getOrCreateTerminal(); + } catch (error) { + if (error instanceof CancellationError) { + return undefined; + } + throw error; + } + if (this._store.isDisposed) { + return undefined; + } + let vt; + try { + vt = await this._getCommandOutputAsVT(this._xtermTerminal); + } catch { + // ignore and treat as no output + } if (!vt) { return undefined; } - if (!vt.text) { - return { lineCount: 0 }; + if (this._store.isDisposed) { + return undefined; + } + + await new Promise(resolve => { + if (!this._lastVT) { + if (vt.text) { + detached.xterm.write(vt.text, resolve); + } else { + resolve(); + } + } else { + const appended = vt.text.slice(this._lastVT.length); + if (appended) { + detached.xterm.write(appended, resolve); + } else { + resolve(); + } + } + }); + + this._lastVT = vt.text; + + const sourceRaw = this._xtermTerminal.raw; + if (sourceRaw) { + this._sourceRaw = sourceRaw; + this._lastUpToDateCursorY = this._getAbsoluteCursorY(sourceRaw); + if (!this._isStreaming && (!this._command.endMarker || this._command.endMarker.isDisposed)) { + this._startStreaming(sourceRaw); + } + } + + this._lineCount = this._getRenderedLineCount(); + + return { lineCount: this._lineCount }; + } + + private async _getCommandOutputAsVT(source: XtermTerminal): Promise<{ text: string } | undefined> { + if (this._store.isDisposed) { + return undefined; + } + const executedMarker = this._command.executedMarker ?? (this._command as unknown as ICurrentPartialCommand).commandExecutedMarker; + if (!executedMarker) { + return undefined; + } + + const endMarker = this._command.endMarker; + const text = await source.getRangeAsVT(executedMarker, endMarker, endMarker?.line !== executedMarker.line); + if (this._store.isDisposed) { + return undefined; + } + if (!text) { + return { text: '' }; } - const detached = await this._getTerminal(); + + return { text }; + } + + private _getRenderedLineCount(): number { + // Calculate line count from the command's markers when available + const endMarker = this._command.endMarker; + if (this._command.executedMarker && endMarker && !endMarker.isDisposed) { + const startLine = this._command.executedMarker.line; + const endLine = endMarker.line; + return Math.max(endLine - startLine, 0); + } + + // During streaming (no end marker), calculate from the source terminal buffer + const executedMarker = this._command.executedMarker ?? (this._command as unknown as ICurrentPartialCommand).commandExecutedMarker; + if (executedMarker && this._sourceRaw) { + const buffer = this._sourceRaw.buffer.active; + const currentLine = buffer.baseY + buffer.cursorY; + return Math.max(currentLine - executedMarker.line, 0); + } + + return this._lineCount; + } + + private async _getOrCreateTerminal(): Promise { + if (this._detachedTerminal) { + return this._detachedTerminal; + } + if (this._detachedTerminalPromise) { + return this._detachedTerminalPromise; + } + if (this._store.isDisposed) { + throw new CancellationError(); + } + const createPromise = (async () => { + const colorProvider = { + getBackgroundColor: (theme: IColorTheme) => getChatTerminalBackgroundColor(theme, this._contextKeyService) + }; + const detached = await this._terminalService.createDetachedTerminal({ + cols: this._xtermTerminal.raw.cols ?? ChatTerminalMirrorMetrics.MirrorColCountFallback, + rows: ChatTerminalMirrorMetrics.MirrorRowCount, + readonly: false, + processInfo: new DetachedProcessInfo({ initialCwd: '' }), + disableOverviewRuler: true, + colorProvider + }); + if (this._store.isDisposed) { + detached.dispose(); + throw new CancellationError(); + } + this._detachedTerminal = detached; + this._register(detached); + + // Forward input from the mirror terminal to the source terminal + this._register(detached.onData(data => this._onDidInputEmitter.fire(data))); + return detached; + })(); + this._detachedTerminalPromise = createPromise; + return createPromise; + } + + private _startStreaming(raw: RawXtermTerminal): void { + if (this._store.isDisposed || this._isStreaming) { + return; + } + this._isStreaming = true; + this._streamingDisposables.add(Event.any(raw.onCursorMove, raw.onLineFeed, raw.onWriteParsed)(() => this._handleCursorEvent())); + this._streamingDisposables.add(raw.onData(() => this._handleCursorEvent())); + } + + private _stopStreaming(): void { + if (!this._isStreaming) { + return; + } + this._streamingDisposables.clear(); + this._isStreaming = false; + this._lowestDirtyCursorY = undefined; + this._sourceRaw = undefined; + } + + private _handleCursorEvent(): void { + if (this._store.isDisposed || !this._sourceRaw) { + return; + } + const cursorY = this._getAbsoluteCursorY(this._sourceRaw); + this._lowestDirtyCursorY = this._lowestDirtyCursorY === undefined ? cursorY : Math.min(this._lowestDirtyCursorY, cursorY); + this._scheduleFlush(); + } + + private _scheduleFlush(): void { + if (this._dirtyScheduled || this._store.isDisposed) { + return; + } + this._dirtyScheduled = true; + queueMicrotask(() => { + this._dirtyScheduled = false; + if (this._store.isDisposed) { + return; + } + this._flushDirtyRange(); + }); + } + + private _flushDirtyRange(): void { + if (this._store.isDisposed || this._flushPromise) { + return; + } + this._flushPromise = this._doFlushDirtyRange().finally(() => { + this._flushPromise = undefined; + }); + } + + private async _doFlushDirtyRange(): Promise { + if (this._store.isDisposed) { + return; + } + const sourceRaw = this._xtermTerminal.raw; + let detached = this._detachedTerminal; + if (!detached) { + try { + detached = await this._getOrCreateTerminal(); + } catch (error) { + if (error instanceof CancellationError) { + return; + } + throw error; + } + } + if (this._store.isDisposed) { + return; + } + const detachedRaw = detached?.xterm; + if (!sourceRaw || !detachedRaw) { + return; + } + + this._sourceRaw = sourceRaw; + const currentCursor = this._getAbsoluteCursorY(sourceRaw); + const previousCursor = this._lastUpToDateCursorY ?? currentCursor; + const startCandidate = this._lowestDirtyCursorY ?? currentCursor; + this._lowestDirtyCursorY = undefined; + + const startLine = Math.min(previousCursor, startCandidate); + // Ensure we resolve any pending flush even when no actual new output is available. + const vt = await this._getCommandOutputAsVT(this._xtermTerminal); + if (!vt) { + return; + } + if (this._store.isDisposed) { + return; + } + + if (vt.text === this._lastVT) { + this._lastUpToDateCursorY = currentCursor; + if (this._command.endMarker && !this._command.endMarker.isDisposed) { + this._stopStreaming(); + } + return; + } + + const canAppend = !!this._lastVT && startLine >= previousCursor; await new Promise(resolve => { - detached.xterm.write(vt.text, () => resolve()); + if (!this._lastVT || !canAppend) { + if (vt.text) { + detachedRaw.write(vt.text, resolve); + } else { + resolve(); + } + } else { + const appended = vt.text.slice(this._lastVT.length); + if (appended) { + detachedRaw.write(appended, resolve); + } else { + resolve(); + } + } }); - return { lineCount: vt.lineCount }; + + this._lastVT = vt.text; + this._lineCount = this._getRenderedLineCount(); + this._lastUpToDateCursorY = currentCursor; + this._onDidUpdateEmitter.fire(this._lineCount); + + if (this._command.endMarker && !this._command.endMarker.isDisposed) { + this._stopStreaming(); + } + } + + private _getAbsoluteCursorY(raw: RawXtermTerminal): number { + return raw.buffer.active.baseY + raw.buffer.active.cursorY; } } @@ -192,7 +476,10 @@ export class DetachedTerminalCommandMirror extends DetachedTerminalMirror implem * Mirrors a terminal output snapshot into a detached terminal instance. * Used when the terminal has been disposed of but we still want to show the output. */ -export class DetachedTerminalSnapshotMirror extends DetachedTerminalMirror { +export class DetachedTerminalSnapshotMirror extends Disposable { + private _detachedTerminal: Promise | undefined; + private _attachedContainer: HTMLElement | undefined; + private _output: IChatTerminalToolInvocationData['terminalCommandOutput'] | undefined; private _container: HTMLElement | undefined; private _dirty = true; @@ -207,9 +494,9 @@ export class DetachedTerminalSnapshotMirror extends DetachedTerminalMirror { super(); this._output = output; const processInfo = this._register(new DetachedProcessInfo({ initialCwd: '' })); - this._setDetachedTerminal(this._terminalService.createDetachedTerminal({ - cols: 80, - rows: 10, + this._detachedTerminal = this._terminalService.createDetachedTerminal({ + cols: ChatTerminalMirrorMetrics.MirrorColCountFallback, + rows: ChatTerminalMirrorMetrics.MirrorRowCount, readonly: true, processInfo, disableOverviewRuler: true, @@ -219,7 +506,14 @@ export class DetachedTerminalSnapshotMirror extends DetachedTerminalMirror { return getChatTerminalBackgroundColor(theme, this._contextKeyService, storedBackground); } } - })); + }).then(terminal => this._register(terminal)); + } + + private async _getTerminal(): Promise { + if (!this._detachedTerminal) { + throw new Error('Detached terminal not initialized'); + } + return this._detachedTerminal; } public setOutput(output: IChatTerminalToolInvocationData['terminalCommandOutput'] | undefined): void { @@ -228,7 +522,14 @@ export class DetachedTerminalSnapshotMirror extends DetachedTerminalMirror { } public async attach(container: HTMLElement): Promise { - await this._attachToContainer(container); + const terminal = await this._getTerminal(); + container.classList.add('chat-terminal-output-terminal'); + const needsAttach = this._attachedContainer !== container || container.firstChild === null; + if (needsAttach) { + terminal.attachToElement(container, { enableGpu: false }); + this._attachedContainer = container; + } + this._container = container; this._applyTheme(container); } diff --git a/src/vs/workbench/contrib/terminal/browser/detachedTerminal.ts b/src/vs/workbench/contrib/terminal/browser/detachedTerminal.ts index 606240a14d805..ff94e3496e809 100644 --- a/src/vs/workbench/contrib/terminal/browser/detachedTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/detachedTerminal.ts @@ -20,6 +20,7 @@ import { TerminalWidgetManager } from './widgets/widgetManager.js'; import { XtermTerminal } from './xterm/xtermTerminal.js'; import { IEnvironmentVariableInfo } from '../common/environmentVariable.js'; import { ITerminalProcessInfo, ProcessState } from '../common/terminal.js'; +import { Event } from '../../../../base/common/event.js'; export class DetachedTerminal extends Disposable implements IDetachedTerminalInstance { private readonly _widgets = this._register(new TerminalWidgetManager()); @@ -32,6 +33,7 @@ export class DetachedTerminal extends Disposable implements IDetachedTerminalIns public get xterm(): IDetachedXtermTerminal { return this._xterm; } + public readonly onData: Event; constructor( private readonly _xterm: XtermTerminal, @@ -39,6 +41,7 @@ export class DetachedTerminal extends Disposable implements IDetachedTerminalIns @IInstantiationService instantiationService: IInstantiationService, ) { super(); + this.onData = this._xterm.raw.onData; const capabilities = options.capabilities ?? new TerminalCapabilityStore(); this._register(capabilities); this.capabilities = capabilities; diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 70f66a2fbfb80..4444764f1a7e3 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -28,7 +28,7 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { GroupIdentifier } from '../../../common/editor.js'; import { ACTIVE_GROUP_TYPE, AUX_WINDOW_GROUP_TYPE, SIDE_GROUP_TYPE } from '../../../services/editor/common/editorService.js'; import type { ICurrentPartialCommand } from '../../../../platform/terminal/common/capabilities/commandDetection/terminalCommand.js'; -import type { IXtermCore } from './xterm-private.js'; +import type { IXtermCore, IBufferSet } from './xterm-private.js'; import type { IMenu } from '../../../../platform/actions/common/actions.js'; import type { IProgressState } from '@xterm/addon-progress'; import type { IEditorOptions } from '../../../../platform/editor/common/editor.js'; @@ -418,6 +418,11 @@ export interface IBaseTerminalInstance { export interface IDetachedTerminalInstance extends IDisposable, IBaseTerminalInstance { readonly xterm: IDetachedXtermTerminal; + /** + * Event fired when data is received from the terminal. + */ + onData: Event; + /** * Attached the terminal to the given element. This should be preferred over * calling {@link IXtermTerminal.attachToElement} so that extra DOM elements @@ -1378,11 +1383,11 @@ export interface IXtermTerminal extends IDisposable { /** * Gets the content between two markers as VT sequences. - * @param startMarker The marker to start from. - * @param endMarker The marker to end at. + * @param startMarker The marker to start from. When not provided, will start from 0. + * @param endMarker The marker to end at. When not provided, will end at the last line. * @param skipLastLine Whether the last line should be skipped (e.g. when it's the prompt line) */ - getRangeAsVT(startMarker: IXtermMarker, endMarker?: IXtermMarker, skipLastLine?: boolean): Promise; + getRangeAsVT(startMarker?: IXtermMarker, endMarker?: IXtermMarker, skipLastLine?: boolean): Promise; /** * Gets whether there's any terminal selection. @@ -1483,6 +1488,11 @@ export interface IDetachedXtermTerminal extends IXtermTerminal { * Resizes the terminal. */ resize(columns: number, rows: number): void; + + /** + * Access to the terminal buffer for reading cursor position and content. + */ + readonly buffer: IBufferSet; } export interface IInternalXtermTerminal { diff --git a/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts b/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts index 23729e0c8d1d6..534c69a196658 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts @@ -31,3 +31,13 @@ export interface IXtermCore { }; }; } + +export interface IBufferSet { + readonly active: { + readonly baseY: number; + readonly cursorY: number; + readonly cursorX: number; + readonly length: number; + getLine(y: number): { translateToString(trimRight?: boolean): string } | undefined; + }; +} diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 17db39e35daff..282a8e3f30365 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -46,7 +46,7 @@ import { equals } from '../../../../../base/common/objects.js'; import type { IProgressState } from '@xterm/addon-progress'; import type { CommandDetectionCapability } from '../../../../../platform/terminal/common/capabilities/commandDetectionCapability.js'; import { URI } from '../../../../../base/common/uri.js'; -import { assert } from '../../../../../base/common/assert.js'; +import { isNumber } from '../../../../../base/common/types.js'; const enum RenderConstants { SmoothScrollDuration = 125 @@ -107,6 +107,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach get lastInputEvent(): string | undefined { return this._lastInputEvent; } private _progressState: IProgressState = { state: 0, value: 0 }; get progressState(): IProgressState { return this._progressState; } + get buffer() { return this.raw.buffer; } // Always on addons private _markNavigationAddon: MarkNavigationAddon; @@ -920,22 +921,24 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this._onDidRequestRefreshDimensions.fire(); } - async getRangeAsVT(startMarker: IXtermMarker, endMarker?: IXtermMarker, skipLastLine?: boolean): Promise { + async getRangeAsVT(startMarker?: IXtermMarker, endMarker?: IXtermMarker, skipLastLine?: boolean): Promise { if (!this._serializeAddon) { const Addon = await this._xtermAddonLoader.importAddon('serialize'); this._serializeAddon = new Addon(); this.raw.loadAddon(this._serializeAddon); } - assert(startMarker.line !== -1); - let end = endMarker?.line ?? this.raw.buffer.active.length - 1; - if (skipLastLine) { + const hasValidEndMarker = isNumber(endMarker?.line); + const start = isNumber(startMarker?.line) && startMarker?.line > -1 ? startMarker.line : 0; + let end = hasValidEndMarker ? endMarker.line : this.raw.buffer.active.length - 1; + if (skipLastLine && hasValidEndMarker) { end = end - 1; } + end = Math.max(end, start); return this._serializeAddon.serialize({ range: { - start: startMarker.line, - end: end + start: startMarker?.line ?? 0, + end } }); } diff --git a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts new file mode 100644 index 0000000000000..d66eb7fa7af7a --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { strictEqual } from 'assert'; +import { importAMDNodeModule } from '../../../../../amdX.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import type { IEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { TerminalCapabilityStore } from '../../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; +import { XtermTerminal } from '../../browser/xterm/xtermTerminal.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { TestXtermAddonImporter } from './xterm/xtermTestUtils.js'; + +const defaultTerminalConfig = { + fontFamily: 'monospace', + fontWeight: 'normal', + fontWeightBold: 'normal', + gpuAcceleration: 'off', + scrollback: 10, + fastScrollSensitivity: 2, + mouseWheelScrollSensitivity: 1, + unicodeVersion: '6' +}; + +suite('Workbench - ChatTerminalCommandMirror', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + suite('VT mirroring with XtermTerminal', () => { + let instantiationService: TestInstantiationService; + let configurationService: TestConfigurationService; + let XTermBaseCtor: typeof Terminal; + + async function createXterm(cols = 80, rows = 10, scrollback = 10): Promise { + const capabilities = store.add(new TerminalCapabilityStore()); + return store.add(instantiationService.createInstance(XtermTerminal, undefined, XTermBaseCtor, { + cols, + rows, + xtermColorProvider: { getBackgroundColor: () => undefined }, + capabilities, + disableShellIntegrationReporting: true, + xtermAddonImporter: new TestXtermAddonImporter(), + }, undefined)); + } + + function write(xterm: XtermTerminal, data: string): Promise { + return new Promise(resolve => xterm.write(data, resolve)); + } + + function getBufferText(xterm: XtermTerminal): string { + const buffer = xterm.raw.buffer.active; + const lines: string[] = []; + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + lines.push(line?.translateToString(true) ?? ''); + } + // Trim trailing empty lines + while (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + return lines.join('\n'); + } + + async function mirrorViaVT(source: XtermTerminal, startLine = 0): Promise { + const startMarker = source.raw.registerMarker(startLine - source.raw.buffer.active.baseY - source.raw.buffer.active.cursorY); + const vt = await source.getRangeAsVT(startMarker ?? undefined, undefined, true); + startMarker?.dispose(); + + const mirror = await createXterm(source.raw.cols, source.raw.rows); + if (vt) { + await write(mirror, vt); + } + return mirror; + } + + setup(async () => { + configurationService = new TestConfigurationService({ + editor: { + fastScrollSensitivity: 2, + mouseWheelScrollSensitivity: 1 + } as Partial, + files: {}, + terminal: { + integrated: defaultTerminalConfig + }, + }); + + instantiationService = workbenchInstantiationService({ + configurationService: () => configurationService + }, store); + + XTermBaseCtor = (await importAMDNodeModule('@xterm/xterm', 'lib/xterm.js')).Terminal; + }); + + test('single character', async () => { + const source = await createXterm(); + await write(source, 'X'); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('single line', async () => { + const source = await createXterm(); + await write(source, 'hello world'); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('multiple lines', async () => { + const source = await createXterm(); + await write(source, 'line 1\r\nline 2\r\nline 3'); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('wrapped line', async () => { + const source = await createXterm(20, 10); // narrow terminal + const longLine = 'a'.repeat(50); // exceeds 20 cols + await write(source, longLine); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('content with special characters', async () => { + const source = await createXterm(); + await write(source, 'hello\ttab\r\nspaces here\r\n$pecial!@#%^&*'); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('content with ANSI colors', async () => { + const source = await createXterm(); + await write(source, '\x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m \x1b[34mblue\x1b[0m'); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('content filling visible area', async () => { + const source = await createXterm(80, 5); + for (let i = 1; i <= 5; i++) { + await write(source, `line ${i}\r\n`); + } + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('content with scrollback (partial buffer)', async () => { + const source = await createXterm(80, 5, 5); // 5 rows visible, 5 scrollback = 10 total + // Write enough to push into scrollback + for (let i = 1; i <= 12; i++) { + await write(source, `line ${i}\r\n`); + } + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('empty content', async () => { + const source = await createXterm(); + + const mirror = await mirrorViaVT(source); + + strictEqual(getBufferText(mirror), getBufferText(source)); + }); + + test('content from marker to cursor', async () => { + const source = await createXterm(); + await write(source, 'before\r\n'); + const startMarker = source.raw.registerMarker(0)!; + await write(source, 'output line 1\r\noutput line 2'); + + const vt = await source.getRangeAsVT(startMarker, undefined, true); + const mirror = await createXterm(); + if (vt) { + await write(mirror, vt); + } + startMarker.dispose(); + + // Mirror should contain just the content from marker onwards + const mirrorText = getBufferText(mirror); + strictEqual(mirrorText.includes('output line 1'), true); + strictEqual(mirrorText.includes('output line 2'), true); + strictEqual(mirrorText.includes('before'), false); + }); + + test('incremental mirroring appends correctly', async () => { + const source = await createXterm(); + const marker = source.raw.registerMarker(0)!; + await write(source, 'initial\r\n'); + + // First mirror with initial content + const vt1 = await source.getRangeAsVT(marker, undefined, true) ?? ''; + const mirror = await createXterm(); + await write(mirror, vt1); + + // Add more content to source + await write(source, 'added\r\n'); + const vt2 = await source.getRangeAsVT(marker, undefined, true) ?? ''; + + // Append only the new part to mirror + const appended = vt2.slice(vt1.length); + if (appended) { + await write(mirror, appended); + } + + // Create a fresh mirror with full VT to compare against + const freshMirror = await createXterm(); + await write(freshMirror, vt2); + + marker.dispose(); + + // Incremental mirror should match fresh mirror + strictEqual(getBufferText(mirror), getBufferText(freshMirror)); + }); + }); +}); diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts index 83287031fbf41..8db8fe5cffc78 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTerminal.test.ts @@ -3,8 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { WebglAddon } from '@xterm/addon-webgl'; -import type { IEvent, Terminal } from '@xterm/xterm'; +import type { Terminal } from '@xterm/xterm'; import { deepStrictEqual, strictEqual } from 'assert'; import { importAMDNodeModule } from '../../../../../../amdX.js'; import { Color, RGBA } from '../../../../../../base/common/color.js'; @@ -22,40 +21,10 @@ import { XtermTerminal } from '../../../browser/xterm/xtermTerminal.js'; import { ITerminalConfiguration, TERMINAL_VIEW_ID } from '../../../common/terminal.js'; import { registerColors, TERMINAL_BACKGROUND_COLOR, TERMINAL_CURSOR_BACKGROUND_COLOR, TERMINAL_CURSOR_FOREGROUND_COLOR, TERMINAL_FOREGROUND_COLOR, TERMINAL_INACTIVE_SELECTION_BACKGROUND_COLOR, TERMINAL_SELECTION_BACKGROUND_COLOR, TERMINAL_SELECTION_FOREGROUND_COLOR } from '../../../common/terminalColorRegistry.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; -import { IXtermAddonNameToCtor, XtermAddonImporter } from '../../../browser/xterm/xtermAddonImporter.js'; +import { TestWebglAddon, TestXtermAddonImporter } from './xtermTestUtils.js'; registerColors(); -class TestWebglAddon implements WebglAddon { - static shouldThrow = false; - static isEnabled = false; - readonly onChangeTextureAtlas = new Emitter().event as IEvent; - readonly onAddTextureAtlasCanvas = new Emitter().event as IEvent; - readonly onRemoveTextureAtlasCanvas = new Emitter().event as IEvent; - readonly onContextLoss = new Emitter().event as IEvent; - constructor(preserveDrawingBuffer?: boolean) { - } - activate() { - TestWebglAddon.isEnabled = !TestWebglAddon.shouldThrow; - if (TestWebglAddon.shouldThrow) { - throw new Error('Test webgl set to throw'); - } - } - dispose() { - TestWebglAddon.isEnabled = false; - } - clearTextureAtlas() { } -} - -class TestXtermAddonImporter extends XtermAddonImporter { - override async importAddon(name: T): Promise { - if (name === 'webgl') { - return TestWebglAddon as unknown as IXtermAddonNameToCtor[T]; - } - return super.importAddon(name); - } -} - export class TestViewDescriptorService implements Partial { private _location = ViewContainerLocation.Panel; private _onDidChangeLocation = new Emitter<{ views: IViewDescriptor[]; from: ViewContainerLocation; to: ViewContainerLocation }>(); diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTestUtils.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTestUtils.ts new file mode 100644 index 0000000000000..bff945f742abb --- /dev/null +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/xtermTestUtils.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { WebglAddon } from '@xterm/addon-webgl'; +import type { IEvent } from '@xterm/xterm'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { XtermAddonImporter, type IXtermAddonNameToCtor } from '../../../browser/xterm/xtermAddonImporter.js'; + +export class TestWebglAddon implements WebglAddon { + static shouldThrow = false; + static isEnabled = false; + private readonly _onChangeTextureAtlas = new Emitter(); + private readonly _onAddTextureAtlasCanvas = new Emitter(); + private readonly _onRemoveTextureAtlasCanvas = new Emitter(); + private readonly _onContextLoss = new Emitter(); + readonly onChangeTextureAtlas = this._onChangeTextureAtlas.event as IEvent; + readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event as IEvent; + readonly onRemoveTextureAtlasCanvas = this._onRemoveTextureAtlasCanvas.event as IEvent; + readonly onContextLoss = this._onContextLoss.event as IEvent; + constructor(preserveDrawingBuffer?: boolean) { + } + activate(): void { + TestWebglAddon.isEnabled = !TestWebglAddon.shouldThrow; + if (TestWebglAddon.shouldThrow) { + throw new Error('Test webgl set to throw'); + } + } + dispose(): void { + TestWebglAddon.isEnabled = false; + this._onChangeTextureAtlas.dispose(); + this._onAddTextureAtlasCanvas.dispose(); + this._onRemoveTextureAtlasCanvas.dispose(); + this._onContextLoss.dispose(); + } + clearTextureAtlas(): void { } +} + +export class TestXtermAddonImporter extends XtermAddonImporter { + override async importAddon(name: T): Promise { + if (name === 'webgl') { + return TestWebglAddon as unknown as IXtermAddonNameToCtor[T]; + } + return super.importAddon(name); + } +} +