diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatTerminalToolProgressPart.css b/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatTerminalToolProgressPart.css index ef98d3c718095..6c639e232ed57 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatTerminalToolProgressPart.css +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatTerminalToolProgressPart.css @@ -157,14 +157,36 @@ } .chat-terminal-output-container.expanded { display: block; } -.chat-terminal-output-container > .monaco-scrollable-element { +.chat-terminal-output-container > .monaco-scrollable-element, +.chat-terminal-output-container > .chat-terminal-output-scroll-host { width: 100%; } +.chat-terminal-output-container:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} +.chat-terminal-output-scroll-host { + display: block; + outline: none; +} +.chat-terminal-output-scroll-host:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} .chat-terminal-output-body { padding: 4px 6px; max-width: 100%; - height: 100%; box-sizing: border-box; + min-height: 0; +} +.chat-terminal-output-terminal { + min-height: 40px; +} +.chat-terminal-output-terminal.chat-terminal-output-terminal-no-output { + display: none; +} +.chat-terminal-output-terminal.chat-terminal-output-terminal .xterm-decoration-overview-ruler { + display: none; } .chat-terminal-output-content { display: flex; diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index bd29767e09e65..62eb3fe59c694 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -23,26 +23,23 @@ import '../media/chatTerminalToolProgressPart.css'; import { TerminalContribSettingId } from '../../../../terminal/terminalContribExports.js'; import { ConfigurationTarget } from '../../../../../../platform/configuration/common/configuration.js'; import type { ICodeBlockRenderOptions } from '../../codeBlockPart.js'; -import { ChatConfiguration, CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES } from '../../../common/constants.js'; +import { ChatConfiguration } from '../../../common/constants.js'; import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; import { MenuId, MenuRegistry } from '../../../../../../platform/actions/common/actions.js'; -import { IChatTerminalToolProgressPart, ITerminalChatService, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../../../terminal/browser/terminal.js'; +import { IChatTerminalToolProgressPart, ITerminalChatService, ITerminalConfigurationService, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalService, type IDetachedTerminalInstance } from '../../../../terminal/browser/terminal.js'; +import { DetachedProcessInfo } from '../../../../terminal/browser/detachedTerminal.js'; +import { TerminalInstanceColorProvider } from '../../../../terminal/browser/terminalInstance.js'; import { Action, IAction } from '../../../../../../base/common/actions.js'; -import { Disposable, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, ImmortalReference, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { DecorationSelector, getTerminalCommandDecorationState, getTerminalCommandDecorationTooltip } from '../../../../terminal/browser/xterm/decorationStyles.js'; import * as dom from '../../../../../../base/browser/dom.js'; -import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; -import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; import { localize } from '../../../../../../nls.js'; import { TerminalLocation } from '../../../../../../platform/terminal/common/terminal.js'; import { ITerminalCommand, TerminalCapability, type ICommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { URI } from '../../../../../../base/common/uri.js'; -import * as domSanitize from '../../../../../../base/browser/domSanitize.js'; -import { DomSanitizerConfig } from '../../../../../../base/browser/domSanitize.js'; -import { allowedMarkdownHtmlAttributes } from '../../../../../../base/browser/markdownRenderer.js'; import { stripIcons } from '../../../../../../base/common/iconLabels.js'; import { IAccessibleViewService } from '../../../../../../platform/accessibility/browser/accessibleView.js'; import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; @@ -51,23 +48,23 @@ import { ChatContextKeys } from '../../../common/chatContextKeys.js'; import { EditorPool } from '../chatContentCodePools.js'; import { KeybindingWeight, KeybindingsRegistry } from '../../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { removeAnsiEscapeCodes } from '../../../../../../base/common/strings.js'; +import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; +import type { IMarker as IXtermMarker } from '@xterm/xterm'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { ChatTerminalStreamingModel } from '../../chatTerminalStreamingModel.js'; +import { ChatTerminalCommandStreamer } from '../../chatTerminalCommandStreamer.js'; const MAX_TERMINAL_OUTPUT_PREVIEW_HEIGHT = 200; -const sanitizerConfig = Object.freeze({ - allowedTags: { - augment: ['b', 'i', 'u', 'code', 'span', 'div', 'body', 'pre'], - }, - allowedAttributes: { - augment: [...allowedMarkdownHtmlAttributes, 'style'] - } -}); - /** * Remembers whether a tool invocation was last expanded so state survives virtualization re-renders. */ const expandedStateByInvocation = new WeakMap(); +const MIN_OUTPUT_HEIGHT = 20; + /** * Options for configuring a terminal command decoration. */ @@ -153,12 +150,9 @@ class TerminalCommandDecoration extends Disposable { duration: command.duration ?? existingState.duration }; storedState = terminalData.terminalCommandState; - } else if (!this._options.terminalData.terminalCommandOutput) { - if (!storedState) { - const now = Date.now(); - terminalData.terminalCommandState = { exitCode: undefined, timestamp: now }; - storedState = terminalData.terminalCommandState; - } + } else if (!storedState) { + terminalData.terminalCommandState = { exitCode: undefined, timestamp: Date.now() }; + storedState = terminalData.terminalCommandState; } const decorationState = getTerminalCommandDecorationState(command, storedState); @@ -230,6 +224,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart private _terminalInstance: ITerminalInstance | undefined; private readonly _decoration: TerminalCommandDecoration; + private readonly _streamingController: ChatTerminalCommandStreamer; + private markdownPart: ChatMarkdownContentPart | undefined; public get codeblocks(): IChatCodeBlockInfo[] { return this.markdownPart?.codeblocks ?? []; @@ -257,8 +253,9 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart @ITerminalService private readonly _terminalService: ITerminalService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, - @IAccessibleViewService private readonly _accessibleViewService: IAccessibleViewService, @IKeybindingService private readonly _keybindingService: IKeybindingService, + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, + @ILogService private readonly _logService: ILogService, ) { super(toolInvocation); @@ -286,6 +283,12 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart getIconElement: () => undefined, getResolvedCommand: () => this._getResolvedCommand() })); + this._streamingController = this._register(new ChatTerminalCommandStreamer( + this._logService, + () => this._store.isDisposed, + (instance, command, force) => this._syncStreamingSnapshot(instance, command, force) + )); + this._streamingController.trackedCommandId = this._terminalData.terminalCommandId ?? this._storedCommandId; const command = terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original; const displayCommand = stripIcons(command); @@ -306,20 +309,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._onDidChangeHeight.fire(); })); - - const outputViewOptions: ChatTerminalToolOutputSectionOptions = { - container: elements.output, - title: elements.title, - displayCommand, - terminalData: this._terminalData, - accessibleViewService: this._accessibleViewService, - onDidChangeHeight: () => this._onDidChangeHeight.fire(), - ensureTerminalInstance: () => this._ensureTerminalInstance(), - resolveCommand: () => this._getResolvedCommand(), - getTerminalTheme: () => this._terminalInstance?.xterm?.getXtermTheme() ?? this._terminalData.terminalTheme, - getStoredCommandId: () => this._storedCommandId - }; - this._outputView = this._register(new ChatTerminalToolOutputSection(outputViewOptions)); + const initialRowHeight = this._computeRowHeightPx(); + this._outputView = this._register(this._instantiationService.createInstance(ChatTerminalToolOutputSection, elements.output, initialRowHeight, elements.title, displayCommand, this._terminalData, () => this._onDidChangeHeight.fire(), () => this._createDetachedTerminal())); this._register(this._outputView.onDidFocus(() => this._handleOutputFocus())); this._register(this._outputView.onDidBlur(e => this._handleOutputBlur(e))); this._register(toDisposable(() => this._handleDispose())); @@ -327,7 +318,9 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._focusAction.value?.refreshKeybindingTooltip(); this._showOutputAction.value?.refreshKeybindingTooltip(); })); - + this._register(this._terminalConfigurationService.onConfigChanged(() => { + this._outputView.updateRowHeight(this._computeRowHeightPx()); + })); const actionBarEl = h('.chat-terminal-action-bar@actionBar'); elements.title.append(actionBarEl.root); @@ -387,11 +380,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart return; } - // Ensure stored output surfaces immediately even if no terminal instance is available yet. - if (this._terminalData.terminalCommandOutput) { - this._addActions(undefined, terminalToolSessionId); - } - const attachInstance = async (instance: ITerminalInstance | undefined) => { if (this._store.isDisposed) { return; @@ -473,8 +461,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart if (!resolvedCommand) { resolvedCommand = this._getResolvedCommand(); } - const hasStoredOutput = !!this._terminalData.terminalCommandOutput; - if (!resolvedCommand && !hasStoredOutput) { + const hasRenderableOutput = this._outputView.hasRenderableOutput(); + if (!resolvedCommand && !hasRenderableOutput && !this._streamingController.streamingCommand) { return; } let showOutputAction = this._showOutputAction.value; @@ -515,10 +503,11 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart delete this._terminalData.terminalToolSessionId; } this._decoration.update(); + this._streamingController.clearAllCommandState(); } private _registerInstanceListener(terminalInstance: ITerminalInstance): void { - const commandDetectionListener = this._register(new MutableDisposable()); + const commandDetectionListener = this._register(new MutableDisposable()); const tryResolveCommand = async (): Promise => { const resolvedCommand = this._resolveCommand(terminalInstance); this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); @@ -527,19 +516,18 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart const attachCommandDetection = async (commandDetection: ICommandDetectionCapability | undefined) => { commandDetectionListener.clear(); + this._streamingController.resetForCommandDetection(this._terminalData.terminalCommandId ?? this._storedCommandId); if (!commandDetection) { await tryResolveCommand(); return; } + const store = new DisposableStore(); + commandDetectionListener.value = store; - commandDetectionListener.value = commandDetection.onCommandFinished(() => { - this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); - commandDetectionListener.clear(); - }); - const resolvedImmediately = await tryResolveCommand(); - if (resolvedImmediately?.endMarker) { - return; - } + store.add(commandDetection.onCommandExecuted(command => this._startStreaming(terminalInstance, command))); + store.add(commandDetection.onCommandFinished(async command => await this._handleCommandFinished(terminalInstance, command, commandDetectionListener))); + + await tryResolveCommand(); }; attachCommandDetection(terminalInstance.capabilities.get(TerminalCapability.CommandDetection)); @@ -562,6 +550,73 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart })); } + private async _handleCommandFinished(terminalInstance: ITerminalInstance | undefined, command: ITerminalCommand, commandDetectionListener: MutableDisposable): Promise { + if (!terminalInstance || this._store.isDisposed) { + return; + } + const finishedId = command.id; + const handledById = this._streamingController.isTrackedCommand(finishedId); + if (!handledById) { + return; + } + if (finishedId && this._terminalData.terminalCommandId !== finishedId) { + this._terminalData.terminalCommandId = finishedId; + } + this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); + const appliedEmptyOutput = this._tryApplyEmptyOutput(command); + if (!appliedEmptyOutput) { + await this._streamingController.queueStreaming(terminalInstance, command, true); + } + this._outputView.endStreaming(); + this._streamingController.endStreaming(); + commandDetectionListener.clear(); + } + + private _startStreaming(terminalInstance: ITerminalInstance, command: ITerminalCommand): void { + if (this._streamingController.streamingCommand) { + return; + } + const commandId = command.id; + const expectedId = this._streamingController.trackedCommandId ?? this._terminalData.terminalCommandId ?? this._storedCommandId; + const commandMatchesExpected = expectedId !== undefined && commandId !== undefined && commandId === expectedId; + if (!commandMatchesExpected) { + return; + } + const streamingStore = this._streamingController.beginStreaming(command, expectedId); + if (commandId && this._terminalData.terminalCommandId !== commandId) { + this._terminalData.terminalCommandId = commandId; + } + this._outputView.beginStreaming(); + this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); + let capturing = true; + streamingStore.add(toDisposable(() => { capturing = false; })); + + const runIfStreaming = (callback: (currentCommand: ITerminalCommand) => void): void => { + if (!capturing || streamingStore.isDisposed) { + return; + } + const latestCommand = this._streamingController.streamingCommand; + if (!latestCommand || latestCommand !== command) { + return; + } + callback(latestCommand); + }; + + this._streamingController.queueStreaming(terminalInstance, command); + streamingStore.add(terminalInstance.onData(() => { + runIfStreaming(currentCommand => this._streamingController.queueStreaming(terminalInstance, currentCommand)); + })); + streamingStore.add(terminalInstance.onLineData(() => { + runIfStreaming(currentCommand => this._outputView.handleCompletedTerminalLine(terminalInstance, currentCommand)); + })); + const xterm = terminalInstance.xterm; + if (xterm) { + streamingStore.add(xterm.raw.onCursorMove(() => { + runIfStreaming(currentCommand => this._outputView.handleCursorRenderableCheck(terminalInstance, currentCommand)); + })); + } + } + private _removeFocusAction(): void { if (this._store.isDisposed) { return; @@ -586,11 +641,32 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart return didChange; } - private async _ensureTerminalInstance(): Promise { - if (!this._terminalInstance && this._terminalData.terminalToolSessionId) { - this._terminalInstance = await this._terminalChatService.getTerminalInstanceByToolSessionId(this._terminalData.terminalToolSessionId); + private _computeRowHeightPx(): number { + const configLineHeight = this._terminalConfigurationService.config.lineHeight && this._terminalConfigurationService.config.lineHeight > 0 + ? this._terminalConfigurationService.config.lineHeight + : 1; + try { + const window = dom.getActiveWindow(); + const font = this._terminalConfigurationService.getFont(window); + const charHeight = font.charHeight && font.charHeight > 0 ? font.charHeight : font.fontSize; + const rowHeight = charHeight * font.lineHeight; + return Math.max(Math.ceil(rowHeight), MIN_OUTPUT_HEIGHT); + } catch { + const fallback = this._terminalConfigurationService.config.fontSize * configLineHeight; + return Math.max(Math.ceil(fallback), MIN_OUTPUT_HEIGHT); } - return this._terminalInstance; + } + + private async _createDetachedTerminal(): Promise { + const targetRef = this._terminalInstance?.targetRef ?? new ImmortalReference(undefined); + const colorProvider = this._instantiationService.createInstance(TerminalInstanceColorProvider, targetRef); + return this._terminalService.createDetachedTerminal({ + cols: this._terminalInstance?.cols ?? 80, + rows: 10, + readonly: true, + processInfo: new DetachedProcessInfo({ initialCwd: '' }), + colorProvider + }); } private _handleOutputFocus(): void { @@ -611,6 +687,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart private _handleDispose(): void { this._terminalOutputContextKey.reset(); this._terminalChatService.clearFocusedProgressPart(this); + this._streamingController.clearAllCommandState(); } public getCommandAndOutputAsText(): string | undefined { @@ -669,19 +746,132 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart return commands.find(c => c.id === this._terminalData.terminalCommandId); } -} -interface ChatTerminalToolOutputSectionOptions { - container: HTMLElement; - title: HTMLElement; - displayCommand: string; - terminalData: IChatTerminalToolInvocationData; - accessibleViewService: IAccessibleViewService; - onDidChangeHeight: () => void; - ensureTerminalInstance: () => Promise; - resolveCommand: () => ITerminalCommand | undefined; - getTerminalTheme: () => { background?: string; foreground?: string } | undefined; - getStoredCommandId: () => string | undefined; + + private async _syncStreamingSnapshot(instance: ITerminalInstance, command: ITerminalCommand, force: boolean): Promise { + if (!instance || this._store.isDisposed || (!force && this._streamingController.streamingCommand !== command)) { + return; + } + const xterm = instance.xterm; + if (!xterm) { + return; + } + const markers = this._resolveCommandMarkers(command); + const startMarker = markers.start; + const endMarker = markers.end; + if (!startMarker || startMarker.line === -1) { + this._logService.trace('chatTerminalToolProgressPart.syncStreamingSnapshot.waitForStartMarker', { commandId: command.id, force }); + setTimeout(() => { + if (this._store.isDisposed || (!force && this._streamingController.streamingCommand !== command)) { + return; + } + this._streamingController.queueStreaming(instance, command, force); + }, 0); + return; + } + const endMarkerForRange = this._getStreamingRangeEndMarker(startMarker, endMarker, force); + const data = await xterm.getRangeAsVT(startMarker, endMarkerForRange); + if (this._store.isDisposed || (!force && this._streamingController.streamingCommand !== command) || !data || data.length === 0) { + if (!data || data.length === 0) { + this._logService.trace('chatTerminalToolProgressPart.syncStreamingSnapshot.waitForData', { commandId: command.id, force }); + setTimeout(() => { + if (this._store.isDisposed || (!force && this._streamingController.streamingCommand !== command)) { + return; + } + this._streamingController.queueStreaming(instance, command, force); + }, 0); + } + return; + } + const stored = this._terminalData.terminalCommandOutput?.text ?? ''; + if (data === stored) { + const handled = this._streamingController.handleEmptySnapshotNoChange(force, stored.length, command.hasOutput(), command, () => { + setTimeout(() => { + if (this._store.isDisposed || (!force && this._streamingController.streamingCommand !== command)) { + return; + } + this._streamingController.queueStreaming(instance, command, force); + }, 0); + }); + if (handled) { + return; + } + this._logService.trace('chatTerminalToolProgressPart.syncStreamingSnapshot.noChange', { commandId: command.id, length: data.length }); + return; + } + this._streamingController.resetEmptySnapshotRetries(); + this._logService.trace('chatTerminalToolProgressPart.syncStreamingSnapshot.apply', { + commandId: command.id, + length: data.length, + appended: Math.max(0, data.length - stored.length) + }); + this._outputView.applyStreamingSnapshot(data); + } + + private _resolveCommandMarkers(command: ITerminalCommand): { start: IXtermMarker | undefined; end: IXtermMarker | undefined } { + type CommandMarkers = { + endMarker?: IXtermMarker; + commandFinishedMarker?: IXtermMarker; + executedMarker?: IXtermMarker; + commandExecutedMarker?: IXtermMarker; + }; + + const candidate = command as unknown as CommandMarkers; + const start = candidate.executedMarker + ?? candidate.commandExecutedMarker + ?? (command.marker as unknown as IXtermMarker | undefined); + const end = candidate.endMarker ?? candidate.commandFinishedMarker; + return { start, end }; + } + + private _getStreamingRangeEndMarker(start: IXtermMarker | undefined, end: IXtermMarker | undefined, force: boolean): IXtermMarker | undefined { + if (!end || end.line === -1) { + return undefined; + } + if (!force || !start || start.line === -1) { + return end; + } + const trimmedLine = end.line - 1; + if (trimmedLine < start.line) { + return end; + } + return this._createStaticMarker(trimmedLine); + } + + private _createStaticMarker(line: number): IXtermMarker { + return { + id: -1, + line, + isDisposed: false, + onDispose: Event.None, + dispose: () => { /* no-op */ } + }; + } + + private _tryApplyEmptyOutput(command: ITerminalCommand): boolean { + // When a command produces no output, the serialize addon can still capture the prompt, + // which visually leaks the prompt. Detect the narrow marker range and explicitly treat it + // as empty so we render the "no output" message instead of the prompt itself. + // We only call getOutput if the marker range is small to avoid performance issues. + const markers = this._resolveCommandMarkers(command); + const startLine = command.marker?.line ?? markers.start?.line; + const endLine = markers.end?.line ?? command.endMarker?.line; + if ( + startLine === undefined || endLine === undefined || + startLine === -1 || endLine === -1 || + endLine - startLine > 2 + ) { + return false; + } + + const output = command.getOutput(); + if (output && output.trim().length > 0) { + return false; + } + + this._outputView.applyEmptyOutput(); + return true; + } } class ChatTerminalToolOutputSection extends Disposable { @@ -692,44 +882,56 @@ class ChatTerminalToolOutputSection extends Disposable { return this._container.classList.contains('expanded'); } - private readonly _container: HTMLElement; - private readonly _title: HTMLElement; - private readonly _displayCommand: string; - private readonly _terminalData: IChatTerminalToolInvocationData; - private readonly _accessibleViewService: IAccessibleViewService; - private readonly _onDidChangeHeight: () => void; - private readonly _ensureTerminalInstance: () => Promise; - private readonly _resolveCommand: () => ITerminalCommand | undefined; - private readonly _getTerminalTheme: () => { background?: string; foreground?: string } | undefined; - private readonly _getStoredCommandId: () => string | undefined; - private readonly _outputBody: HTMLElement; - private _outputScrollbar: DomScrollableElement | undefined; - private _outputContent: HTMLElement | undefined; + private readonly _scrollable: DomScrollableElement; + private _terminalContainer: HTMLElement; + private _infoElement: HTMLElement | undefined; + private _rowHeightPx: number; + private readonly _detachedTerminal: MutableDisposable; private _outputResizeObserver: ResizeObserver | undefined; private _renderedOutputHeight: number | undefined; - private _lastOutputTruncated = false; private readonly _outputAriaLabelBase: string; + private readonly _streaming: ChatTerminalStreamingModel; + private _encounteredRenderableOutput = false; + private _xtermElement: HTMLElement | undefined; private readonly _onDidFocusEmitter = new Emitter(); private readonly _onDidBlurEmitter = new Emitter(); - constructor(options: ChatTerminalToolOutputSectionOptions) { + constructor( + private readonly _container: HTMLElement, + rowHeightPx: number, + private readonly _title: HTMLElement, + private readonly _displayCommand: string, + private readonly _terminalData: IChatTerminalToolInvocationData, + private readonly _onDidChangeHeight: () => void, + private readonly _createDetachedTerminal: () => Promise, + @IAccessibleViewService private readonly _accessibleViewService: IAccessibleViewService, + @ILogService private readonly _logService: ILogService, + ) { super(); - this._container = options.container; - this._title = options.title; - this._displayCommand = options.displayCommand; - this._terminalData = options.terminalData; - this._accessibleViewService = options.accessibleViewService; - this._onDidChangeHeight = options.onDidChangeHeight; - this._ensureTerminalInstance = options.ensureTerminalInstance; - this._resolveCommand = options.resolveCommand; - this._getTerminalTheme = options.getTerminalTheme; - this._getStoredCommandId = options.getStoredCommandId; + this._rowHeightPx = rowHeightPx; + this._detachedTerminal = this._register(new MutableDisposable()); + this._outputAriaLabelBase = localize('chatTerminalOutputAriaLabel', 'Terminal output for {0}', this._displayCommand); this._container.classList.add('collapsed'); - this._outputBody = dom.$('.chat-terminal-output-body'); + this._container.tabIndex = -1; + const elements = h('.chat-terminal-output-body@body', [ + h('.chat-terminal-output-terminal@terminal') + ]); + this._outputBody = elements.body; + this._terminalContainer = elements.terminal; + this._scrollable = this._register(new DomScrollableElement(this._outputBody, { + vertical: ScrollbarVisibility.Auto, + horizontal: ScrollbarVisibility.Auto, + handleMouseWheel: true + })); + const scrollableDomNode = this._scrollable.getDomNode(); + scrollableDomNode.tabIndex = 0; + scrollableDomNode.classList.add('chat-terminal-output-scroll-host'); + this._container.appendChild(scrollableDomNode); + this._ensureOutputResizeObserver(); this.onDidFocus = this._onDidFocusEmitter.event; this.onDidBlur = this._onDidBlurEmitter.event; @@ -738,6 +940,24 @@ class ChatTerminalToolOutputSection extends Disposable { this._register(dom.addDisposableListener(this._container, dom.EventType.FOCUS_IN, () => this._onDidFocusEmitter.fire())); this._register(dom.addDisposableListener(this._container, dom.EventType.FOCUS_OUT, event => this._onDidBlurEmitter.fire(event as FocusEvent))); + + this._streaming = new ChatTerminalStreamingModel(this._terminalData, this._logService); + this._streaming.hydrateFromStoredOutput(this._terminalData.terminalCommandOutput?.text); + this._encounteredRenderableOutput = this._streaming.hasRenderableOutput(); + this._setStatusMessages(); + this._updateTerminalVisibility(); + } + + public updateRowHeight(rowHeight: number): void { + if (!Number.isFinite(rowHeight) || rowHeight <= 0 || rowHeight === this._rowHeightPx) { + return; + } + this._rowHeightPx = rowHeight; + if (this.isExpanded) { + this._layoutOutput(); + this._scrollOutputToBottom(); + this._scheduleOutputRelayout(); + } } public async toggle(expanded: boolean): Promise { @@ -754,25 +974,28 @@ class ChatTerminalToolOutputSection extends Disposable { return true; } - const didCreate = await this._renderOutputIfNeeded(); + await this._ensureUiAndReplay(); this._layoutOutput(); this._scrollOutputToBottom(); - if (didCreate) { - this._scheduleOutputRelayout(); - } + this._scheduleOutputRelayout(); return true; } public async ensureRendered(): Promise { - await this._renderOutputIfNeeded(); - if (this.isExpanded) { - this._layoutOutput(); - this._scrollOutputToBottom(); + if (!this.isExpanded) { + return; } + await this._ensureUiAndReplay(); + this._layoutOutput(); + this._scrollOutputToBottom(); } public focus(): void { - this._outputScrollbar?.getDomNode().focus(); + if (this._shouldRenderTerminal()) { + this._container.focus(); + return; + } + this._scrollable.getDomNode().focus(); } public containsElement(element: HTMLElement | null): boolean { @@ -780,142 +1003,249 @@ class ChatTerminalToolOutputSection extends Disposable { } public updateAriaLabel(): void { - if (!this._outputScrollbar) { - return; - } - const scrollableDomNode = this._outputScrollbar.getDomNode(); - scrollableDomNode.setAttribute('role', 'region'); + const shouldRender = this._shouldRenderTerminal(); const accessibleViewHint = this._accessibleViewService.getOpenAriaHint(AccessibilityVerbositySettingId.TerminalChatOutput); - const label = accessibleViewHint - ? this._outputAriaLabelBase + ', ' + accessibleViewHint - : this._outputAriaLabelBase; - scrollableDomNode.setAttribute('aria-label', label); + const label = accessibleViewHint ? `${this._outputAriaLabelBase}, ${accessibleViewHint}` : this._outputAriaLabelBase; + const scrollableDomNode = this._scrollable.getDomNode(); + if (shouldRender) { + this._container.setAttribute('role', 'region'); + this._container.setAttribute('aria-label', label); + scrollableDomNode.removeAttribute('role'); + scrollableDomNode.removeAttribute('aria-label'); + } else { + scrollableDomNode.setAttribute('role', 'region'); + scrollableDomNode.setAttribute('aria-label', label); + this._container.removeAttribute('role'); + this._container.removeAttribute('aria-label'); + } } public getCommandAndOutputAsText(): string | undefined { const commandHeader = localize('chatTerminalOutputAccessibleViewHeader', 'Command: {0}', this._displayCommand); - const command = this._resolveCommand(); - const output = command?.getOutput()?.trimEnd(); - if (!output) { + const bufferText = removeAnsiEscapeCodes(this._streaming.getBufferedText()).trimEnd(); + if (!bufferText) { return `${commandHeader}\n${localize('chat.terminalOutputEmpty', 'No output was produced by the command.')}`; } - let result = `${commandHeader}\n${output}`; - if (this._lastOutputTruncated) { - result += `\n\n${localize('chat.terminalOutputTruncated', 'Output truncated to first {0} lines.', CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES)}`; + return `${commandHeader}\n${bufferText}`; + } + + public appendStreamingData(data: string): boolean { + // Streams raw chunks into the preview buffer and mirrors any appended data into the detached xterm when it's live. + if (!this._streaming.appendData(data)) { + return false; } - return result; + this._mirrorAppendedData(data); + this._ensureRenderableFlagFromStream(); + return true; } - private _setExpanded(expanded: boolean): void { - this._container.classList.toggle('expanded', expanded); - this._container.classList.toggle('collapsed', !expanded); - this._title.classList.toggle('expanded', expanded); + public applyStreamingSnapshot(snapshot: string): void { + // Applies a serialized VT snapshot captured from the command markers. We try to diff the + // new snapshot against the previously seen content to avoid costly full replays. + const result = this._streaming.applySnapshot(snapshot); + let contentMutated = false; + switch (result.kind) { + case 'noop': + return; + case 'append': + this._mirrorAppendedData(result.appended); + contentMutated = true; + break; + case 'replace': + this._handleReplacedStreamingSnapshot(); + contentMutated = true; + break; + } + if (contentMutated) { + this._ensureRenderableFlagFromStream(); + } } - private async _renderOutputIfNeeded(): Promise { - if (this._outputContent) { - this._ensureOutputResizeObserver(); - return false; + public applyEmptyOutput(): void { + // Resets the preview state when the command produced no output so that prompts are not + // surfaced as command output. + this._streaming.applyEmptyOutput(); + this._encounteredRenderableOutput = false; + this._disposeDetachedTerminal(); + this._setStatusMessages(); + this._updateTerminalVisibility(); + this._scrollable.scanDomNode(); + } + + public handleCompletedTerminalLine(instance: ITerminalInstance, command: ITerminalCommand): void { + if (this._encounteredRenderableOutput) { + return; } + if (!this._cursorLineHasRenderableContent(instance, command, -1)) { + return; + } + this._markRenderableOutput(); + } - const terminalInstance = await this._ensureTerminalInstance(); - const output = await this._collectOutput(terminalInstance); - const serializedOutput = output ?? this._getStoredCommandOutput(); - if (!serializedOutput) { - return false; + public handleCursorRenderableCheck(instance: ITerminalInstance, command: ITerminalCommand): void { + if (this._encounteredRenderableOutput) { + return; } - const content = this._renderOutput(serializedOutput).element; - const theme = this._getTerminalTheme(); - if (theme && !content.classList.contains('chat-terminal-output-content-empty')) { - // eslint-disable-next-line no-restricted-syntax - const inlineTerminal = content.querySelector('div'); - if (inlineTerminal) { - inlineTerminal.style.setProperty('background-color', theme.background || 'transparent'); - inlineTerminal.style.setProperty('color', theme.foreground || 'inherit'); - } + if (!this._cursorLineHasRenderableContent(instance, command)) { + return; } + this._markRenderableOutput(); + } - this._outputBody.replaceChildren(content); - this._outputContent = content; - if (!this._outputScrollbar) { - this._outputScrollbar = this._register(new DomScrollableElement(this._outputBody, { - vertical: ScrollbarVisibility.Auto, - horizontal: ScrollbarVisibility.Auto, - handleMouseWheel: true - })); - const scrollableDomNode = this._outputScrollbar.getDomNode(); - scrollableDomNode.tabIndex = 0; - scrollableDomNode.style.maxHeight = `${MAX_TERMINAL_OUTPUT_PREVIEW_HEIGHT}px`; - this._container.appendChild(scrollableDomNode); - this._ensureOutputResizeObserver(); - this._outputContent = undefined; - this._renderedOutputHeight = undefined; + private _mirrorAppendedData(data: string): void { + if (!data) { + return; + } + // Mirror the newest data into the detached terminal when it is visible, otherwise fall back + // to replaying from the buffer the next time the output expands. + if (this.isExpanded && this._detachedTerminal.value) { + this._detachedTerminal.value.xterm.write(data); + this._scrollOutputToBottom(); + this._streaming.clearNeedsReplay(); } else { - this._ensureOutputResizeObserver(); + this._streaming.markNeedsReplay(); } - this.updateAriaLabel(); - return true; + this._logService.trace('chatTerminalOutput.mirrorAppendedData', { + appendedLength: data.length, + immediate: this.isExpanded && !!this._detachedTerminal.value + }); + if (this.isExpanded) { + this._scheduleOutputRelayout(); + } + this._setStatusMessages(); + this._updateTerminalVisibility(); } - private async _collectOutput(terminalInstance: ITerminalInstance | undefined): Promise<{ text: string; truncated: boolean } | undefined> { - const commandDetection = terminalInstance?.capabilities.get(TerminalCapability.CommandDetection); - const commands = commandDetection?.commands; - const xterm = await terminalInstance?.xtermReadyPromise; - if (!commands || commands.length === 0 || !terminalInstance || !xterm) { - return; + private _handleReplacedStreamingSnapshot(): void { + let replayHandled = false; + if (this._detachedTerminal.value && this.isExpanded) { + this._clearDetachedTerminal(); + const buffered = this._streaming.getBufferedText(); + if (buffered) { + this._detachedTerminal.value.xterm.write(buffered); + this._scrollOutputToBottom(); + } + this._streaming.clearNeedsReplay(); + replayHandled = true; } - const commandId = this._terminalData.terminalCommandId ?? this._getStoredCommandId(); - if (!commandId) { + if (!replayHandled) { + this._streaming.markNeedsReplay(); + } + this._logService.trace('chatTerminalOutput.handleReplace', { replayHandled, isExpanded: this.isExpanded }); + this._setStatusMessages(); + this._updateTerminalVisibility(); + if (this.isExpanded) { + this._scheduleOutputRelayout(); + } + } + + private _clearDetachedTerminal(): void { + // Clears the detached xterm prior to replaying content so the terminal reflects the latest + // snapshot exactly. + const instance = this._detachedTerminal.value; + if (!instance) { return; } - const command = commands.find(c => c.id === commandId); - if (!command?.endMarker) { + const xterm = instance.xterm; + if (!xterm) { return; } - const result = await xterm.getCommandOutputAsHtml(command, CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES); - return { text: result.text, truncated: result.truncated ?? false }; + try { + xterm.raw?.clear(); + xterm.write('\x1b[3J\x1b[2J\x1b[H'); + } catch { + // The detached terminal may be mid-dispose; ignore errors when clearing. + } } - private _getStoredCommandOutput(): { text: string; truncated: boolean } | undefined { - const stored = this._terminalData.terminalCommandOutput; - if (!stored?.text) { - return; + public beginStreaming(): void { + // Resets streaming state just before a command starts emitting fresh data. + this._streaming.beginStreaming(); + this._encounteredRenderableOutput = false; + if (this._detachedTerminal.value) { + this._clearDetachedTerminal(); } - return { - text: stored.text, - truncated: stored.truncated ?? false - }; + this._setSupplementalMessages([]); + this._scrollable.scanDomNode(); + this._updateTerminalVisibility(); } - private _renderOutput(result: { text: string; truncated: boolean }): { element: HTMLElement; inlineOutput?: HTMLElement; pre?: HTMLElement } { - this._lastOutputTruncated = result.truncated; - const { content } = h('div.chat-terminal-output-content@content'); - let inlineOutput: HTMLElement | undefined; - let preElement: HTMLElement | undefined; + public endStreaming(): void { + this._streaming.endStreaming(); + this._setStatusMessages(); + this._updateTerminalVisibility(); + } - if (result.text.trim() === '') { - content.classList.add('chat-terminal-output-content-empty'); - const { empty } = h('div.chat-terminal-output-empty@empty'); - empty.textContent = localize('chat.terminalOutputEmpty', 'No output was produced by the command.'); - content.appendChild(empty); + public hasRenderableOutput(): boolean { + return this._encounteredRenderableOutput || this._streaming.hasRenderableOutput(); + } + + private _shouldRenderTerminal(): boolean { + return this._encounteredRenderableOutput || this._streaming.shouldRender(); + } + + private _updateTerminalVisibility(): void { + const shouldRender = this._shouldRenderTerminal(); + const scrollableDomNode = this._scrollable.getDomNode(); + this._terminalContainer.classList.toggle('chat-terminal-output-terminal-no-output', !shouldRender); + this._container.tabIndex = shouldRender ? 0 : -1; + scrollableDomNode.tabIndex = shouldRender ? -1 : 0; + if (!shouldRender) { + this._disposeDetachedTerminal(); } else { - const { pre } = h('pre.chat-terminal-output@pre'); - preElement = pre; - domSanitize.safeSetInnerHtml(pre, result.text, sanitizerConfig); - const firstChild = pre.firstElementChild; - if (dom.isHTMLElement(firstChild)) { - inlineOutput = firstChild; - } - content.appendChild(pre); + this._ensureOutputResizeObserver(); + } + this.updateAriaLabel(); + } + + private _disposeDetachedTerminal(): void { + this._detachedTerminal.clear(); + this._outputResizeObserver?.disconnect(); + this._outputResizeObserver = undefined; + this._xtermElement = undefined; + dom.clearNode(this._terminalContainer); + } + + private _setExpanded(expanded: boolean): void { + this._container.classList.toggle('expanded', expanded); + this._container.classList.toggle('collapsed', !expanded); + this._title.classList.toggle('expanded', expanded); + if (!expanded) { + const domNode = this._scrollable.getDomNode(); + domNode.style.removeProperty('height'); + domNode.style.removeProperty('max-height'); + } + } + + private async _ensureUiAndReplay(): Promise { + if (!this._shouldRenderTerminal()) { + this._updateTerminalVisibility(); + this.updateAriaLabel(); + return; } - if (result.truncated) { - const { info } = h('div.chat-terminal-output-info@info'); - info.textContent = localize('chat.terminalOutputTruncated', 'Output truncated to first {0} lines.', CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES); - content.appendChild(info); + await this._ensureDetachedTerminalInstance(); + if (this._streaming.needsReplay) { + await this._replayBuffer(); } + this._updateTerminalVisibility(); + this.updateAriaLabel(); + } - return { element: content, inlineOutput, pre: preElement }; + private async _replayBuffer(): Promise { + const instance = await this._ensureDetachedTerminalInstance(); + if (!instance) { + return; + } + this._clearDetachedTerminal(); + const concatenated = this._streaming.getBufferedText(); + if (concatenated) { + instance.xterm.write(concatenated); + } + this._streaming.clearNeedsReplay(); + this._logService.trace('chatTerminalOutput.replayBuffer', { length: concatenated.length }); + this._setStatusMessages(); + this._scrollOutputToBottom(); } private _scheduleOutputRelayout(): void { @@ -926,51 +1256,211 @@ class ChatTerminalToolOutputSection extends Disposable { } private _layoutOutput(): void { - if (!this._outputScrollbar || !this.isExpanded) { + if (!this._terminalContainer || !this.isExpanded) { return; } - const scrollableDomNode = this._outputScrollbar.getDomNode(); - const viewportHeight = Math.min(this._getOutputContentHeight(), MAX_TERMINAL_OUTPUT_PREVIEW_HEIGHT); - scrollableDomNode.style.height = `${viewportHeight}px`; - this._outputScrollbar.scanDomNode(); - if (this._renderedOutputHeight !== viewportHeight) { - this._renderedOutputHeight = viewportHeight; + const contentHeight = Math.max(this._calculateVisibleContentHeight(), MIN_OUTPUT_HEIGHT); + const clampedHeight = Math.min(contentHeight, MAX_TERMINAL_OUTPUT_PREVIEW_HEIGHT); + const measuredBodyHeight = Math.max(this._outputBody.scrollHeight, MIN_OUTPUT_HEIGHT); + const appliedHeight = Math.min(clampedHeight, measuredBodyHeight); + const domNode = this._scrollable?.getDomNode(); + if (domNode) { + domNode.style.maxHeight = `${MAX_TERMINAL_OUTPUT_PREVIEW_HEIGHT}px`; + domNode.style.height = `${appliedHeight}px`; + this._scrollable?.scanDomNode(); + } + if (this._renderedOutputHeight !== appliedHeight) { + this._renderedOutputHeight = appliedHeight; this._onDidChangeHeight(); } } private _scrollOutputToBottom(): void { - if (!this._outputScrollbar) { - return; - } - const dimensions = this._outputScrollbar.getScrollDimensions(); - this._outputScrollbar.setScrollPosition({ scrollTop: dimensions.scrollHeight }); - } - - private _getOutputContentHeight(): number { - const firstChild = this._outputBody.firstElementChild as HTMLElement | null; - if (!firstChild) { - return this._outputBody.scrollHeight; - } - const style = dom.getComputedStyle(this._outputBody); - const paddingTop = Number.parseFloat(style.paddingTop || '0'); - const paddingBottom = Number.parseFloat(style.paddingBottom || '0'); - const padding = paddingTop + paddingBottom; - return firstChild.scrollHeight + padding; + this._scrollable.scanDomNode(); + const dimensions = this._scrollable.getScrollDimensions(); + this._scrollable.setScrollPosition({ scrollTop: dimensions.scrollHeight }); } private _ensureOutputResizeObserver(): void { - if (this._outputResizeObserver || !this._outputScrollbar) { + if (this._outputResizeObserver || !this._terminalContainer) { return; } const observer = new ResizeObserver(() => this._layoutOutput()); - observer.observe(this._container); + observer.observe(this._terminalContainer); this._outputResizeObserver = observer; this._register(toDisposable(() => { observer.disconnect(); this._outputResizeObserver = undefined; })); } + + private _calculateVisibleContentHeight(): number { + const lineCount = this._countStreamLines(); + const effectiveLines = Math.max(lineCount, 1); + const infoHeight = this._infoElement?.offsetHeight ?? 0; + const hasOutput = this._streaming.hasRenderableOutput(); + if (!hasOutput && !this._streaming.isStreaming) { + return infoHeight; + } + return Math.max(effectiveLines * this._rowHeightPx + infoHeight, this._rowHeightPx); + } + + + private async _ensureDetachedTerminalInstance(): Promise { + if (!this._shouldRenderTerminal()) { + return undefined; + } + const existing = this._detachedTerminal.value; + if (existing) { + if (!this._xtermElement) { + this._captureXtermElement(existing); + } + return existing; + } + try { + const instance = await this._createDetachedTerminal(); + this._detachedTerminal.value = instance; + if (!instance) { + return undefined; + } + instance.attachToElement(this._terminalContainer); + this._captureXtermElement(instance); + this._scrollable.scanDomNode(); + return instance; + } catch { + return undefined; + } + } + + private _captureXtermElement(instance: IDetachedTerminalInstance): void { + const rawElement = instance.xterm.getElement(); + if (!rawElement) { + this._xtermElement = undefined; + return; + } + + this._xtermElement = rawElement; + if (this._outputResizeObserver) { + this._outputResizeObserver.disconnect(); + this._outputResizeObserver = undefined; + } + this._ensureOutputResizeObserver(); + } + + private _setStatusMessages(): void { + const messages: string[] = []; + const storedOutput = this._terminalData.terminalCommandOutput?.text ?? ''; + const storedHasContent = removeAnsiEscapeCodes(storedOutput).replace(/\r/g, '').trim().length > 0; + const hasOutput = storedHasContent || this._encounteredRenderableOutput || this._streaming.hasRenderableOutput(); + const showEmptyMessage = !hasOutput && !this._streaming.isStreaming; + if (showEmptyMessage) { + this._logService.trace('chatTerminalOutput.statusMessage.emptyOutput'); + messages.push(localize('chat.terminalOutputEmpty', 'No output was produced by the command.')); + } + this._setSupplementalMessages(messages); + } + + private _setSupplementalMessages(messages: string[]): void { + const hasContent = messages.some(message => message.trim().length > 0); + if (!hasContent) { + if (this._infoElement) { + this._infoElement.remove(); + this._infoElement = undefined; + } + this._scrollable.scanDomNode(); + return; + } + if (!this._infoElement) { + this._infoElement = dom.$('div.chat-terminal-output-info'); + this._outputBody.appendChild(this._infoElement); + } + this._infoElement.textContent = messages.join('\n\n'); + this._scrollable.scanDomNode(); + } + + private _countStreamLines(): number { + const fromStreaming = this._streaming.countRenderableLines(); + if (fromStreaming > 0) { + return fromStreaming; + } + const storedOutput = this._terminalData.terminalCommandOutput?.text; + if (!storedOutput) { + return 0; + } + const sanitized = removeAnsiEscapeCodes(storedOutput).replace(/\r/g, ''); + if (!sanitized.length) { + return 0; + } + return sanitized.split('\n').length; + } + + private _markRenderableOutput(): void { + if (this._encounteredRenderableOutput) { + return; + } + this._streaming.markRenderableOutput(); + this._encounteredRenderableOutput = true; + this._setStatusMessages(); + this._updateTerminalVisibility(); + } + + private _cursorLineHasRenderableContent(instance: ITerminalInstance, command: ITerminalCommand, relativeLineOffset = 0): boolean { + const xterm = instance.xterm; + if (!xterm) { + return false; + } + const startMarker = this._resolveCommandStartMarker(command); + if (!startMarker || startMarker.line === -1) { + return false; + } + const buffer = xterm.raw.buffer.active; + const cursorLine = buffer.baseY + buffer.cursorY; + const targetLine = cursorLine + relativeLineOffset; + if (targetLine < 0 || targetLine < startMarker.line) { + return false; + } + const line = buffer.getLine(targetLine); + if (!line) { + return false; + } + let segment = line.translateToString(true); + if (!segment) { + return false; + } + if (targetLine === startMarker.line) { + const executedColumn = command.executedX ?? 0; + segment = executedColumn < segment.length ? segment.slice(executedColumn) : ''; + } + if (!segment) { + return false; + } + if (relativeLineOffset === 0) { + const cursorX = buffer.cursorX; + segment = cursorX < segment.length ? segment.slice(0, cursorX) : segment; + } + return segment.replace(/\r/g, '').trim().length > 0; + } + + private _ensureRenderableFlagFromStream(): void { + if (this._encounteredRenderableOutput) { + return; + } + if (this._streaming.hasRenderableOutput()) { + this._markRenderableOutput(); + } + } + + private _resolveCommandStartMarker(command: ITerminalCommand): IXtermMarker | undefined { + type CommandMarkers = { + executedMarker?: IXtermMarker; + commandExecutedMarker?: IXtermMarker; + marker?: IXtermMarker; + }; + const candidate = command as unknown as CommandMarkers; + return candidate.executedMarker + ?? candidate.commandExecutedMarker + ?? (command.marker as unknown as IXtermMarker | undefined); + } } export const focusMostRecentChatTerminalCommandId = 'workbench.action.chat.focusMostRecentChatTerminal'; diff --git a/src/vs/workbench/contrib/chat/browser/chatTerminalCommandStreamer.ts b/src/vs/workbench/contrib/chat/browser/chatTerminalCommandStreamer.ts new file mode 100644 index 0000000000000..d37eda154239f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatTerminalCommandStreamer.ts @@ -0,0 +1,200 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MutableDisposable, DisposableStore, Disposable } from '../../../../base/common/lifecycle.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js'; +import { ITerminalInstance } from '../../terminal/browser/terminal.js'; +import { IStreamingSnapshotRequest } from './chatTerminalStreamingModel.js'; +import type { IMarker as IXtermMarker } from '@xterm/xterm'; + + +export class ChatTerminalCommandStreamer extends Disposable { + private readonly _commandStreamingListener = this._register(new MutableDisposable()); + private _streamingCommand: ITerminalCommand | undefined; + private _trackedCommandId: string | undefined; + private _streamingQueue: IStreamingSnapshotRequest[] = []; + private _emptySnapshotRetries = 0; + private _isDrainingStreamingQueue = false; + private _streamingDrainScheduled = false; + + constructor( + private readonly _logService: ILogService, + private readonly _isDisposed: () => boolean, + private readonly _syncSnapshot: (instance: ITerminalInstance, command: ITerminalCommand, force: boolean) => Promise + ) { + super(); + } + + public get listener(): MutableDisposable { + return this._commandStreamingListener; + } + + public get streamingCommand(): ITerminalCommand | undefined { + return this._streamingCommand; + } + + public beginStreaming(command: ITerminalCommand, expectedId: string | undefined): DisposableStore { + this._streamingCommand = command; + this._trackedCommandId = command.id ?? expectedId; + this._emptySnapshotRetries = 0; + const store = new DisposableStore(); + this._commandStreamingListener.value = store; + return store; + } + + public endStreaming(): void { + this._commandStreamingListener.clear(); + this._streamingCommand = undefined; + this._trackedCommandId = undefined; + this._emptySnapshotRetries = 0; + this._clearStreamingQueue(); + } + + public resetForCommandDetection(initialTrackedId: string | undefined): void { + this._commandStreamingListener.clear(); + this._streamingCommand = undefined; + this._trackedCommandId = initialTrackedId; + this._emptySnapshotRetries = 0; + this._clearStreamingQueue(); + } + + public clearAllCommandState(): void { + this._commandStreamingListener.clear(); + this._streamingCommand = undefined; + this._trackedCommandId = undefined; + this._emptySnapshotRetries = 0; + this._clearStreamingQueue(); + } + + public get trackedCommandId(): string | undefined { + return this._trackedCommandId; + } + + public set trackedCommandId(value: string | undefined) { + this._trackedCommandId = value; + } + + public isTrackedCommand(candidate: string | undefined): boolean { + return !!candidate && candidate === this._trackedCommandId; + } + + public queueStreaming(instance: ITerminalInstance, command: ITerminalCommand, force = false): Promise { + if (this._isDisposed() || (!force && this._streamingCommand !== command)) { + return Promise.resolve(); + } + + const executedMarker = (command as unknown as { executedMarker?: IXtermMarker; commandExecutedMarker?: IXtermMarker }).executedMarker + ?? (command as unknown as { executedMarker?: IXtermMarker; commandExecutedMarker?: IXtermMarker }).commandExecutedMarker; + if (!executedMarker) { + const commandId = command.id ?? 'unknown'; + this._logService.trace('chatTerminalToolProgressPart.queueStreaming.waitForExecutedMarker', { commandId, force }); + setTimeout(() => { + if (this._isDisposed() || (!force && this._streamingCommand !== command)) { + return; + } + void this.queueStreaming(instance, command, force); + }, 0); + return Promise.resolve(); + } + + const commandId = command.id ?? 'unknown'; + this._logService.trace('chatTerminalToolProgressPart.queueStreaming', { commandId, pending: this._streamingQueue.length + 1, force }); + + return new Promise((resolve, reject) => { + this._streamingQueue.push({ instance, command, force, resolve, reject }); + if (!this._isDrainingStreamingQueue) { + this._scheduleStreamingFlush(); + } + }); + } + + public handleEmptySnapshotNoChange(force: boolean, storedLength: number, hasOutput: boolean, command: ITerminalCommand, requeue: () => void): boolean { + if (!(force && storedLength === 0 && hasOutput)) { + this._emptySnapshotRetries = 0; + return false; + } + + this._emptySnapshotRetries++; + if (this._emptySnapshotRetries <= 60) { + const attempt = this._emptySnapshotRetries; + this._logService.trace('chatTerminalToolProgressPart.syncStreamingSnapshot.retryPendingOutput', { commandId: command.id, attempt }); + requeue(); + return true; + } + + this._logService.trace('chatTerminalToolProgressPart.syncStreamingSnapshot.retryPendingOutput.maxAttempts', { commandId: command.id, attempt: this._emptySnapshotRetries }); + this._emptySnapshotRetries = 0; + return false; + } + + public resetEmptySnapshotRetries(): void { + this._emptySnapshotRetries = 0; + } + + public override dispose(): void { + this._clearStreamingQueue(); + super.dispose(); + } + + private _scheduleStreamingFlush(): void { + if (this._streamingDrainScheduled || this._isDrainingStreamingQueue || this._streamingQueue.length === 0) { + return; + } + this._streamingDrainScheduled = true; + this._logService.trace('chatTerminalToolProgressPart.scheduleStreamingFlush', { commandId: this._streamingCommand?.id, queued: this._streamingQueue.length }); + void this._drainStreamingQueue(); + } + + private async _drainStreamingQueue(): Promise { + this._streamingDrainScheduled = false; + if (this._isDrainingStreamingQueue || this._streamingQueue.length === 0) { + return; + } + + this._isDrainingStreamingQueue = true; + this._logService.trace('chatTerminalToolProgressPart.drainStreamingQueue-start', { queued: this._streamingQueue.length, commandId: this._streamingCommand?.id }); + try { + while (this._streamingQueue.length) { + const job = this._streamingQueue.shift()!; + if (this._isDisposed() || (!job.force && this._streamingCommand !== job.command)) { + job.resolve(); + this._logService.trace('chatTerminalToolProgressPart.drainStreamingQueue-skip', { commandId: job.command.id, force: job.force }); + continue; + } + try { + await this._syncSnapshot(job.instance, job.command, job.force); + job.resolve(); + this._logService.trace('chatTerminalToolProgressPart.drainStreamingQueue-run', { commandId: job.command.id, force: job.force }); + } catch (error) { + job.reject(error); + this._logService.trace('chatTerminalToolProgressPart.drainStreamingQueue-error', { commandId: job.command.id, force: job.force, message: error instanceof Error ? error.message : String(error) }); + } + } + } finally { + this._isDrainingStreamingQueue = false; + this._logService.trace('chatTerminalToolProgressPart.drainStreamingQueue-end', { remaining: this._streamingQueue.length, commandId: this._streamingCommand?.id }); + if (this._streamingQueue.length) { + this._scheduleStreamingFlush(); + } + } + } + + private _clearStreamingQueue(error?: unknown): void { + this._streamingDrainScheduled = false; + if (!this._streamingQueue.length) { + return; + } + this._logService.trace('chatTerminalToolProgressPart.clearStreamingQueue', { pending: this._streamingQueue.length, hasError: error !== undefined }); + const pending = this._streamingQueue.splice(0, this._streamingQueue.length); + for (const job of pending) { + if (error !== undefined) { + job.reject(error); + } else { + job.resolve(); + } + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatTerminalStreamingModel.ts b/src/vs/workbench/contrib/chat/browser/chatTerminalStreamingModel.ts new file mode 100644 index 0000000000000..07821082a3f34 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatTerminalStreamingModel.ts @@ -0,0 +1,170 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js'; +import { ITerminalInstance } from '../../terminal/browser/terminal.js'; +import { IChatTerminalToolInvocationData } from '../common/chatService.js'; + +export interface IStreamingSnapshotRequest { + readonly instance: ITerminalInstance; + readonly command: ITerminalCommand; + readonly force: boolean; + readonly resolve: () => void; + readonly reject: (error: unknown) => void; +} + +export type StreamingSnapshotMutation = + | { readonly kind: 'noop' } + | { readonly kind: 'append'; readonly appended: string } + | { readonly kind: 'replace'; readonly snapshot: string }; + +// Encapsulates the rolling buffer of serialized terminal output so the UI only needs to worry +// about mirroring data into the preview. The heavy lifting happens here, including diffing the +// newest VT snapshot to decide when we can append, truncate, or fully replace content. +export class ChatTerminalStreamingModel { + private _isStreaming = false; + private _streamBuffer: string[] = []; + private _needsReplay = false; + private _hasRenderableOutput = false; + + constructor( + private readonly _terminalData: IChatTerminalToolInvocationData, + private readonly _logService: ILogService + ) { } + + public hydrateFromStoredOutput(text: string | undefined): void { + if (!text) { + return; + } + const storedOutput = this._terminalData.terminalCommandOutput ?? (this._terminalData.terminalCommandOutput = { text: '' }); + storedOutput.text = text; + this._streamBuffer = [text]; + this._needsReplay = true; + this._hasRenderableOutput = text.length > 0; + this._logService.trace('chatTerminalStreaming.hydrate', { length: text.length }); + } + + public beginStreaming(): void { + this._isStreaming = true; + this._streamBuffer = []; + this._needsReplay = true; + this._hasRenderableOutput = false; + this._terminalData.terminalCommandOutput = { text: '' }; + this._logService.trace('chatTerminalStreaming.begin'); + } + + public endStreaming(): void { + this._isStreaming = false; + this._logService.trace('chatTerminalStreaming.end'); + } + + public appendData(data: string): boolean { + if (!data) { + return false; + } + this._isStreaming = true; + const storedOutput = this._terminalData.terminalCommandOutput ?? (this._terminalData.terminalCommandOutput = { text: '' }); + this._streamBuffer.push(data); + storedOutput.text += data; + this._hasRenderableOutput = storedOutput.text.length > 0; + this._logService.trace('chatTerminalStreaming.append', { length: data.length, bufferChunks: this._streamBuffer.length }); + return true; + } + + public applySnapshot(snapshot: string): StreamingSnapshotMutation { + const storedOutput = this._terminalData.terminalCommandOutput ?? (this._terminalData.terminalCommandOutput = { text: '' }); + const previous = storedOutput.text ?? ''; + if (snapshot === previous) { + this._logService.trace('chatTerminalStreaming.applySnapshot', { mutation: 'noop', previousLength: previous.length, newLength: snapshot.length }); + return { kind: 'noop' }; + } + if (snapshot.length < previous.length || !snapshot.startsWith(previous)) { + this._streamBuffer = []; + storedOutput.text = ''; + if (snapshot) { + this.appendData(snapshot); + this._needsReplay = true; + } + this._logService.trace('chatTerminalStreaming.applySnapshot', { mutation: 'replace', previousLength: previous.length, newLength: snapshot.length }); + return { kind: 'replace', snapshot }; + } + + const appended = snapshot.slice(previous.length); + if (!appended.length) { + this._logService.trace('chatTerminalStreaming.applySnapshot', { mutation: 'noop', previousLength: previous.length, newLength: snapshot.length }); + return { kind: 'noop' }; + } + + this.appendData(appended); + this._logService.trace('chatTerminalStreaming.applySnapshot', { + mutation: 'append', + appendedLength: appended.length, + previousLength: previous.length, + newLength: snapshot.length + }); + return { kind: 'append', appended }; + } + + public applyEmptyOutput(): void { + this._isStreaming = false; + this._streamBuffer = []; + this._needsReplay = false; + const storedOutput = this._terminalData.terminalCommandOutput ?? (this._terminalData.terminalCommandOutput = { text: '' }); + storedOutput.text = ''; + this._hasRenderableOutput = false; + this._logService.trace('chatTerminalStreaming.applyEmptyOutput'); + } + + public hasRenderableOutput(): boolean { + return this._hasRenderableOutput; + } + + public countRenderableLines(): number { + if (!this._streamBuffer.length) { + return 0; + } + const concatenated = this._streamBuffer.join(''); + const withoutAnsi = removeAnsiEscapeCodes(concatenated); + const sanitized = withoutAnsi.replace(/\r/g, ''); + if (!sanitized.length) { + return 0; + } + return sanitized.split('\n').length; + } + + public get isStreaming(): boolean { + return this._isStreaming; + } + + public shouldRender(): boolean { + return this._isStreaming || this.hasRenderableOutput(); + } + + public get needsReplay(): boolean { + return this._needsReplay; + } + + public markNeedsReplay(): void { + this._needsReplay = true; + } + + public clearNeedsReplay(): void { + this._needsReplay = false; + } + + public getBufferedText(): string { + return this._streamBuffer.join(''); + } + + public markRenderableOutput(): void { + this._hasRenderableOutput = true; + } + + public getBuffer(): readonly string[] { + return this._streamBuffer; + } +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index eee3a039a73d8..8c09e83796235 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -1321,6 +1321,11 @@ export interface IXtermTerminal extends IDisposable { */ attachToElement(container: HTMLElement, options?: Partial): void; + /** + * Gets the DOM element of the terminal. + */ + getElement(): HTMLElement | undefined; + findResult?: { resultIndex: number; resultCount: number }; /** @@ -1421,6 +1426,11 @@ export interface IXtermTerminal extends IDisposable { */ getContentsAsHtml(): Promise; + /** + * Gets the contents of the buffer from a start marker to an end marker as VT sequences. + */ + getRangeAsVT(startMarker: IXtermMarker, endMarker?: IXtermMarker): Promise; + /** * Refreshes the terminal after it has been moved. */ @@ -1430,6 +1440,10 @@ export interface IXtermTerminal extends IDisposable { } export interface IDetachedXtermTerminal extends IXtermTerminal { + /** + * The raw xterm.js terminal instance, this is only available when the terminal is attached + */ + raw?: RawXtermTerminal; /** * Writes data to the terminal. * @param data data to write diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index a6869fe4e98d9..ca85b67cdd2b0 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -46,6 +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'; const enum RenderConstants { SmoothScrollDuration = 125 @@ -168,6 +169,10 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach return dom.isAncestorOfActiveElement(this.raw.element); } + public getElement(): HTMLElement | undefined { + return this.raw.element; + } + /** * @param xtermCtor The xterm.js constructor, this is passed in so it can be fetched lazily * outside of this class such that {@link raw} is not nullable. @@ -891,6 +896,24 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this._onDidRequestRefreshDimensions.fire(); } + async getRangeAsVT(startMarker: IXtermMarker, endMarker?: IXtermMarker): Promise { + if (!this._serializeAddon) { + const Addon = await this._xtermAddonLoader.importAddon('serialize'); + this._serializeAddon = new Addon(); + this.raw.loadAddon(this._serializeAddon); + } + + assert(startMarker.line !== -1); + + return this._serializeAddon.serialize({ + range: { + start: startMarker.line, + end: endMarker?.line ?? this.raw.buffer.active.length - 1 + } + }); + } + + getXtermTheme(theme?: IColorTheme): ITheme { if (!theme) { theme = this._themeService.getColorTheme(); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalCommandArtifactCollector.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalCommandArtifactCollector.ts index c5d2be40fca00..11193f0804bbc 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalCommandArtifactCollector.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalCommandArtifactCollector.ts @@ -5,9 +5,8 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IChatTerminalToolInvocationData } from '../../../../chat/common/chatService.js'; -import { CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES } from '../../../../chat/common/constants.js'; import { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; -import { TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; +import { ITerminalCommand, TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; export class TerminalCommandArtifactCollector { @@ -19,7 +18,7 @@ export class TerminalCommandArtifactCollector { toolSpecificData: IChatTerminalToolInvocationData, instance: ITerminalInstance, commandId: string | undefined, - fallbackOutput?: string + _fallbackOutput?: string ): Promise { if (commandId) { try { @@ -27,25 +26,16 @@ export class TerminalCommandArtifactCollector { } catch (error) { this._logService.warn(`RunInTerminalTool: Failed to create terminal command URI for ${commandId}`, error); } - - const serialized = await this._tryGetSerializedCommandOutput(toolSpecificData, instance, commandId); - if (serialized) { - toolSpecificData.terminalCommandOutput = { text: serialized.text, truncated: serialized.truncated }; + const command = this._resolveCommand(instance, commandId); + if (command) { toolSpecificData.terminalCommandState = { - exitCode: serialized.exitCode, - timestamp: serialized.timestamp, - duration: serialized.duration + exitCode: command.exitCode, + timestamp: command.timestamp, + duration: command.duration }; - this._applyTheme(toolSpecificData, instance); - return; } } - - if (fallbackOutput !== undefined) { - const normalized = fallbackOutput.replace(/\r\n/g, '\n'); - toolSpecificData.terminalCommandOutput = { text: normalized, truncated: false }; - this._applyTheme(toolSpecificData, instance); - } + this._applyTheme(toolSpecificData, instance); } private _applyTheme(toolSpecificData: IChatTerminalToolInvocationData, instance: ITerminalInstance): void { @@ -61,31 +51,9 @@ export class TerminalCommandArtifactCollector { return instance.resource.with({ query: params.toString() }); } - private async _tryGetSerializedCommandOutput(toolSpecificData: IChatTerminalToolInvocationData, instance: ITerminalInstance, commandId: string): Promise<{ text: string; truncated?: boolean; exitCode?: number; timestamp?: number; duration?: number } | undefined> { + private _resolveCommand(instance: ITerminalInstance, commandId: string): ITerminalCommand | undefined { const commandDetection = instance.capabilities.get(TerminalCapability.CommandDetection); const command = commandDetection?.commands.find(c => c.id === commandId); - - if (!command?.endMarker) { - return undefined; - } - - const xterm = await instance.xtermReadyPromise; - if (!xterm) { - return undefined; - } - - try { - const result = await xterm.getCommandOutputAsHtml(command, CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES); - return { - text: result.text, - truncated: result.truncated, - exitCode: command.exitCode, - timestamp: command.timestamp, - duration: command.duration - }; - } catch (error) { - this._logService.warn(`RunInTerminalTool: Failed to serialize command output for ${commandId}`, error); - return undefined; - } + return command; } }