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 313342044f126..6360071e4279e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatTerminalToolProgressPart.css +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatTerminalToolProgressPart.css @@ -151,16 +151,18 @@ .chat-terminal-output-container > .monaco-scrollable-element { width: 100%; } +.chat-terminal-output-container: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-content { - display: flex; - flex-direction: column; - gap: 6px; +.chat-terminal-output-terminal.chat-terminal-output-terminal-no-output { + display: none; } .chat-terminal-output { margin: 0; @@ -169,10 +171,18 @@ } .chat-terminal-output-empty { + display: none; font-style: italic; color: var(--vscode-descriptionForeground); line-height: normal; } +.chat-terminal-output-terminal.chat-terminal-output-terminal-no-output ~ .chat-terminal-output-empty { + display: block; +} + +.chat-terminal-output-container .xterm-scrollable-element .scrollbar { + display: none; +} .chat-terminal-output div, .chat-terminal-output span { 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 78968ca21e87b..03a1137dd36a3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -18,10 +18,10 @@ import { ChatProgressSubPart } from '../chatProgressContentPart.js'; import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; import '../media/chatTerminalToolProgressPart.css'; import type { ICodeBlockRenderOptions } from '../../codeBlockPart.js'; -import { CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES } from '../../../common/constants.js'; -import { IChatTerminalToolProgressPart, ITerminalChatService, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../../../terminal/browser/terminal.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 { Emitter, Event } from '../../../../../../base/common/event.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'; import * as dom from '../../../../../../base/browser/dom.js'; @@ -32,9 +32,6 @@ import { ITerminalCommand, TerminalCapability, type ICommandDetectionCapability import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.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'; @@ -42,23 +39,16 @@ import { AccessibilityVerbositySettingId } from '../../../../accessibility/brows import { ChatContextKeys } from '../../../common/chatContextKeys.js'; import { EditorPool } from '../chatContentCodePools.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { DetachedTerminalCommandMirror } from '../../../../terminal/browser/chatTerminalCommandMirror.js'; import { TerminalLocation } from '../../../../../../platform/terminal/common/terminal.js'; -import { Action, IAction } from '../../../../../../base/common/actions.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { TerminalContribCommandId } from '../../../../terminal/terminalContribExports.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { isNumber } from '../../../../../../base/common/types.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'] - } -}); +const MIN_OUTPUT_ROWS = 1; +const MAX_OUTPUT_ROWS = 10; /** * Remembers whether a tool invocation was last expanded so state survives virtualization re-renders. @@ -96,7 +86,6 @@ interface ITerminalCommandDecorationOptions { getResolvedCommand(): ITerminalCommand | undefined; } - class TerminalCommandDecoration extends Disposable { private readonly _element: HTMLElement; private _interactionElement: HTMLElement | undefined; @@ -159,12 +148,10 @@ 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) { + const now = Date.now(); + terminalData.terminalCommandState = { exitCode: undefined, timestamp: now }; + storedState = terminalData.terminalCommandState; } const decorationState = getTerminalCommandDecorationState(command, storedState); @@ -249,7 +236,6 @@ 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, ) { super(toolInvocation); @@ -268,12 +254,10 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart h('.chat-terminal-content-title@title', [ h('.chat-terminal-command-block@commandBlock') ]), - h('.chat-terminal-content-message@message'), - h('.chat-terminal-output-container@output') + h('.chat-terminal-content-message@message') ]); const command = terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original; - const displayCommand = stripIcons(command); this._terminalOutputContextKey = ChatContextKeys.inChatTerminalToolOutput.bindTo(this._contextKeyService); this._decoration = this._register(this._instantiationService.createInstance(TerminalCommandDecoration, { @@ -298,20 +282,13 @@ 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)); + this._outputView = this._register(this._instantiationService.createInstance( + ChatTerminalToolOutputSection, + () => this._onDidChangeHeight.fire(), + () => this._ensureTerminalInstance(), + () => this._getResolvedCommand(), + )); + elements.container.append(this._outputView.domNode); this._register(this._outputView.onDidFocus(() => this._handleOutputFocus())); this._register(this._outputView.onDidBlur(e => this._handleOutputBlur(e))); this._register(toDisposable(() => this._handleDispose())); @@ -379,11 +356,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; @@ -465,8 +437,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart if (!resolvedCommand) { resolvedCommand = this._getResolvedCommand(); } - const hasStoredOutput = !!this._terminalData.terminalCommandOutput; - if (!resolvedCommand && !hasStoredOutput) { + if (!resolvedCommand) { return; } let showOutputAction = this._showOutputAction.value; @@ -663,78 +634,63 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } } -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; -} - class ChatTerminalToolOutputSection extends Disposable { - public readonly onDidFocus: Event; - public readonly onDidBlur: Event; + public readonly domNode: HTMLElement; public get isExpanded(): boolean { - return this._container.classList.contains('expanded'); + return this.domNode.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 _outputResizeObserver: ResizeObserver | undefined; + private _scrollableContainer: DomScrollableElement | undefined; private _renderedOutputHeight: number | undefined; - private _lastOutputTruncated = false; - private readonly _outputAriaLabelBase: string; + private _mirror: DetachedTerminalCommandMirror | undefined; + private readonly _contentContainer: HTMLElement; + private readonly _terminalContainer: HTMLElement; + private readonly _emptyElement: HTMLElement; - private readonly _onDidFocusEmitter = new Emitter(); - private readonly _onDidBlurEmitter = new Emitter(); + private readonly _onDidFocusEmitter = this._register(new Emitter()); + public get onDidFocus() { return this._onDidFocusEmitter.event; } + private readonly _onDidBlurEmitter = this._register(new Emitter()); + public get onDidBlur() { return this._onDidBlurEmitter.event; } - constructor(options: ChatTerminalToolOutputSectionOptions) { + constructor( + private readonly _onDidChangeHeight: () => void, + private readonly _ensureTerminalInstance: () => Promise, + private readonly _resolveCommand: () => ITerminalCommand | undefined, + @IAccessibleViewService private readonly _accessibleViewService: IAccessibleViewService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService + ) { 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._outputAriaLabelBase = localize('chatTerminalOutputAriaLabel', 'Terminal output for {0}', this._displayCommand); - - this._container.classList.add('collapsed'); - this._outputBody = dom.$('.chat-terminal-output-body'); - - this.onDidFocus = this._onDidFocusEmitter.event; - this.onDidBlur = this._onDidBlurEmitter.event; - this._register(this._onDidFocusEmitter); - this._register(this._onDidBlurEmitter); - - 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))); + + const containerElements = h('.chat-terminal-output-container@container', [ + h('.chat-terminal-output-body@body', [ + h('.chat-terminal-output-content@content', [ + h('.chat-terminal-output-terminal@terminal'), + h('.chat-terminal-output-empty@empty') + ]) + ]) + ]); + this.domNode = containerElements.container; + this.domNode.classList.add('collapsed'); + this._outputBody = containerElements.body; + this._contentContainer = containerElements.content; + this._terminalContainer = containerElements.terminal; + + this._emptyElement = containerElements.empty; + this._contentContainer.appendChild(this._emptyElement); + + this._register(dom.addDisposableListener(this.domNode, dom.EventType.FOCUS_IN, () => this._onDidFocusEmitter.fire())); + this._register(dom.addDisposableListener(this.domNode, dom.EventType.FOCUS_OUT, event => this._onDidBlurEmitter.fire(event))); } public async toggle(expanded: boolean): Promise { const currentlyExpanded = this.isExpanded; if (expanded === currentlyExpanded) { + if (expanded) { + await this._updateTerminalContent(); + } return false; } @@ -746,168 +702,120 @@ class ChatTerminalToolOutputSection extends Disposable { return true; } - const didCreate = await this._renderOutputIfNeeded(); + if (!this._scrollableContainer) { + await this._createScrollableContainer(); + } + await this._updateTerminalContent(); 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(); - } - } - public focus(): void { - this._outputScrollbar?.getDomNode().focus(); + this._scrollableContainer?.getDomNode().focus(); } public containsElement(element: HTMLElement | null): boolean { - return !!element && this._container.contains(element); + return !!element && this.domNode.contains(element); } public updateAriaLabel(): void { - if (!this._outputScrollbar) { + if (!this._scrollableContainer) { + return; + } + const command = this._resolveCommand(); + if (!command) { return; } - const scrollableDomNode = this._outputScrollbar.getDomNode(); + const ariaLabel = localize('chatTerminalOutputAriaLabel', 'Terminal output for {0}', command.command); + const scrollableDomNode = this._scrollableContainer.getDomNode(); scrollableDomNode.setAttribute('role', 'region'); const accessibleViewHint = this._accessibleViewService.getOpenAriaHint(AccessibilityVerbositySettingId.TerminalChatOutput); const label = accessibleViewHint - ? this._outputAriaLabelBase + ', ' + accessibleViewHint - : this._outputAriaLabelBase; + ? ariaLabel + ', ' + accessibleViewHint + : ariaLabel; scrollableDomNode.setAttribute('aria-label', label); } public getCommandAndOutputAsText(): string | undefined { - const commandHeader = localize('chatTerminalOutputAccessibleViewHeader', 'Command: {0}', this._displayCommand); const command = this._resolveCommand(); - const output = command?.getOutput()?.trimEnd(); - if (!output) { - return `${commandHeader}\n${localize('chat.terminalOutputEmpty', 'No output was produced by the command.')}`; + if (!command) { + return undefined; } - 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)}`; + const commandHeader = localize('chatTerminalOutputAccessibleViewHeader', 'Command: {0}', command.command); + if (!command) { + return commandHeader; + } + const rawOutput = command.getOutput(); + if (!rawOutput || rawOutput.trim().length === 0) { + return `${commandHeader}\n${localize('chat.terminalOutputEmpty', 'No output was produced by the command.')}`; } - return result; + const lines = rawOutput.split('\n'); + + return `${commandHeader}\n${lines.join('\n').trimEnd()}`; } private _setExpanded(expanded: boolean): void { - this._container.classList.toggle('expanded', expanded); - this._container.classList.toggle('collapsed', !expanded); - this._title.classList.toggle('expanded', expanded); + this.domNode.classList.toggle('expanded', expanded); + this.domNode.classList.toggle('collapsed', !expanded); } - private async _renderOutputIfNeeded(): Promise { - if (this._outputContent) { - this._ensureOutputResizeObserver(); - return false; - } - - const terminalInstance = await this._ensureTerminalInstance(); - const output = await this._collectOutput(terminalInstance); - const serializedOutput = output ?? this._getStoredCommandOutput(); - if (!serializedOutput) { - return false; - } - 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'); - } - } - - 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; - } else { - this._ensureOutputResizeObserver(); - } + private async _createScrollableContainer(): Promise { + this._scrollableContainer = this._register(new DomScrollableElement(this._outputBody, { + vertical: ScrollbarVisibility.Hidden, + horizontal: ScrollbarVisibility.Auto, + handleMouseWheel: true + })); + const scrollableDomNode = this._scrollableContainer.getDomNode(); + scrollableDomNode.tabIndex = 0; + const rowHeight = this._computeRowHeightPx(); + const padding = this._getOutputPadding(); + const maxHeight = rowHeight * MAX_OUTPUT_ROWS + padding; + scrollableDomNode.style.maxHeight = `${maxHeight}px`; + this.domNode.appendChild(scrollableDomNode); this.updateAriaLabel(); - return true; } - 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) { + private async _updateTerminalContent(): Promise { + const terminalInstance = await this._ensureTerminalInstance(); + if (!terminalInstance) { + this._showEmptyMessage(localize('chat.terminalOutputTerminalMissing', 'Terminal is no longer available.')); return; } - const commandId = this._terminalData.terminalCommandId ?? this._getStoredCommandId(); - if (!commandId) { + + const command = this._resolveCommand(); + if (!command) { + this._showEmptyMessage(localize('chat.terminalOutputCommandMissing', 'Command information is not available.')); return; } - const command = commands.find(c => c.id === commandId); - if (!command?.endMarker) { - return; + if (!this._mirror) { + await terminalInstance.xtermReadyPromise; + this._mirror = this._register(this._instantiationService.createInstance(DetachedTerminalCommandMirror, terminalInstance.xterm!, command)); } - const result = await xterm.getCommandOutputAsHtml(command, CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES); - return { text: result.text, truncated: result.truncated ?? false }; - } - - private _getStoredCommandOutput(): { text: string; truncated: boolean } | undefined { - const stored = this._terminalData.terminalCommandOutput; - if (!stored?.text) { + await this._mirror.attach(this._terminalContainer); + const result = await this._mirror.renderCommand(); + if (!result) { + this._showEmptyMessage(localize('chat.terminalOutputPending', 'Command output will appear here once available.')); return; } - return { - text: stored.text, - truncated: stored.truncated ?? false - }; - } - 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; - - 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); + if (result.lineCount === 0) { + this._showEmptyMessage(localize('chat.terminalOutputEmpty', 'No output was produced by the command.')); } 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._hideEmptyMessage(); } + this._layoutOutput(result.lineCount); + } - 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); - } + private _showEmptyMessage(message: string): void { + this._emptyElement.textContent = message; + this._terminalContainer.classList.add('chat-terminal-output-terminal-no-output'); + } - return { element: content, inlineOutput, pre: preElement }; + private _hideEmptyMessage(): void { + this._emptyElement.textContent = ''; + this._terminalContainer.classList.remove('chat-terminal-output-terminal-no-output'); } private _scheduleOutputRelayout(): void { @@ -917,51 +825,58 @@ class ChatTerminalToolOutputSection extends Disposable { }); } - private _layoutOutput(): void { - if (!this._outputScrollbar || !this.isExpanded) { + private _layoutOutput(lineCount?: number): void { + if (!this._scrollableContainer || !this.isExpanded || !lineCount) { 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 scrollableDomNode = this._scrollableContainer.getDomNode(); + const rowHeight = this._computeRowHeightPx(); + const padding = this._getOutputPadding(); + const minHeight = rowHeight * MIN_OUTPUT_ROWS + padding; + const maxHeight = rowHeight * MAX_OUTPUT_ROWS + padding; + const contentHeight = this._getOutputContentHeight(lineCount, rowHeight, padding); + const clampedHeight = Math.min(contentHeight, maxHeight); + const measuredBodyHeight = Math.max(this._outputBody.clientHeight, minHeight); + const appliedHeight = Math.min(clampedHeight, measuredBodyHeight); + scrollableDomNode.style.maxHeight = `${maxHeight}px`; + scrollableDomNode.style.height = `${appliedHeight}px`; + this._scrollableContainer.scanDomNode(); + if (this._renderedOutputHeight !== appliedHeight) { + this._renderedOutputHeight = appliedHeight; this._onDidChangeHeight(); } } private _scrollOutputToBottom(): void { - if (!this._outputScrollbar) { + if (!this._scrollableContainer) { return; } - const dimensions = this._outputScrollbar.getScrollDimensions(); - this._outputScrollbar.setScrollPosition({ scrollTop: dimensions.scrollHeight }); + const dimensions = this._scrollableContainer.getScrollDimensions(); + this._scrollableContainer.setScrollPosition({ scrollTop: dimensions.scrollHeight }); } - private _getOutputContentHeight(): number { - const firstChild = this._outputBody.firstElementChild as HTMLElement | null; - if (!firstChild) { - return this._outputBody.scrollHeight; - } + private _getOutputContentHeight(lineCount: number, rowHeight: number, padding: number): number { + const contentRows = Math.max(lineCount, MIN_OUTPUT_ROWS); + return (contentRows * rowHeight) + padding; + } + + private _getOutputPadding(): number { 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; + return paddingTop + paddingBottom; } - private _ensureOutputResizeObserver(): void { - if (this._outputResizeObserver || !this._outputScrollbar) { - return; - } - const observer = new ResizeObserver(() => this._layoutOutput()); - observer.observe(this._container); - this._outputResizeObserver = observer; - this._register(toDisposable(() => { - observer.disconnect(); - this._outputResizeObserver = undefined; - })); + private _computeRowHeightPx(): number { + const window = dom.getActiveWindow(); + const font = this._terminalConfigurationService.getFont(window); + const hasCharHeight = isNumber(font.charHeight) && font.charHeight > 0; + const hasFontSize = isNumber(font.fontSize) && font.fontSize > 0; + const hasLineHeight = isNumber(font.lineHeight) && font.lineHeight > 0; + const charHeight = (hasCharHeight ? font.charHeight : (hasFontSize ? font.fontSize : 1)) ?? 1; + const lineHeight = hasLineHeight ? font.lineHeight : 1; + const rowHeight = Math.ceil(charHeight * lineHeight); + return Math.max(rowHeight, 1); } } diff --git a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts new file mode 100644 index 0000000000000..82e3aade2daf9 --- /dev/null +++ b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import type { ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js'; +import { ITerminalService, type IDetachedTerminalInstance } from './terminal.js'; +import { DetachedProcessInfo } from './detachedTerminal.js'; +import { XtermTerminal } from './xterm/xtermTerminal.js'; +import { TERMINAL_BACKGROUND_COLOR } from '../common/terminalColorRegistry.js'; +import { PANEL_BACKGROUND } from '../../../common/theme.js'; + +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. + */ +export class DetachedTerminalCommandMirror extends Disposable implements IDetachedTerminalCommandMirror { + private _detachedTerminal: Promise; + private _attachedContainer?: HTMLElement; + + constructor( + private readonly _xtermTerminal: XtermTerminal, + private readonly _command: ITerminalCommand, + @ITerminalService private readonly _terminalService: ITerminalService, + ) { + super(); + this._detachedTerminal = this._createTerminal(); + } + + async attach(container: HTMLElement): Promise { + const terminal = await this._detachedTerminal; + if (this._attachedContainer !== container) { + container.classList.add('chat-terminal-output-terminal'); + terminal.attachToElement(container); + this._attachedContainer = container; + } + } + + async renderCommand(): Promise<{ lineCount?: number } | undefined> { + const vt = await this._getCommandOutputAsVT(); + if (!vt) { + return undefined; + } + if (!vt.text) { + return { lineCount: 0 }; + } + const detached = await this._detachedTerminal; + detached.xterm.write(vt.text); + return { lineCount: vt.lineCount }; + } + + private async _getCommandOutputAsVT(): Promise<{ text: string; lineCount: number } | undefined> { + const executedMarker = this._command.executedMarker; + const endMarker = this._command.endMarker; + if (!executedMarker || executedMarker.isDisposed || !endMarker || endMarker.isDisposed) { + return undefined; + } + + const startLine = executedMarker.line; + const endLine = endMarker.line - 1; + const lineCount = Math.max(endLine - startLine + 1, 0); + + const text = await this._xtermTerminal.getRangeAsVT(executedMarker, endMarker, true); + if (!text) { + return { text: '', lineCount: 0 }; + } + + return { text, lineCount }; + } + + private async _createTerminal(): Promise { + const detached = await this._terminalService.createDetachedTerminal({ + cols: this._xtermTerminal.raw!.cols, + rows: 10, + readonly: true, + processInfo: new DetachedProcessInfo({ initialCwd: '' }), + disableOverviewRuler: true, + colorProvider: { + getBackgroundColor: theme => { + const terminalBackground = theme.getColor(TERMINAL_BACKGROUND_COLOR); + if (terminalBackground) { + return terminalBackground; + } + return theme.getColor(PANEL_BACKGROUND); + }, + } + }); + return this._register(detached); + } + +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index eee3a039a73d8..0798c8feec827 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -332,6 +332,7 @@ export interface IDetachedXTermOptions { capabilities?: ITerminalCapabilityStore; readonly?: boolean; processInfo: ITerminalProcessInfo; + disableOverviewRuler?: boolean; } /** @@ -1343,6 +1344,14 @@ export interface IXtermTerminal extends IDisposable { */ getFont(): ITerminalFont; + /** + * Gets the content between two markers as VT sequences. + * @param startMarker The marker to start from. + * @param endMarker The marker to end at. + * @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; + /** * Gets whether there's any terminal selection. */ diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index f741b47daa729..407af56e6354c 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -1099,6 +1099,7 @@ export class TerminalService extends Disposable implements ITerminalService { rows: options.rows, xtermColorProvider: options.colorProvider, capabilities: options.capabilities || new TerminalCapabilityStore(), + disableOverviewRuler: options.disableOverviewRuler, }, undefined); if (options.readonly) { diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index a6869fe4e98d9..3d0adf6f99a4b 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 @@ -83,6 +84,8 @@ export interface IXtermTerminalOptions { disableShellIntegrationReporting?: boolean; /** The object that imports xterm addons, set this to inject an importer in tests. */ xtermAddonImporter?: XtermAddonImporter; + /** Whether to disable the overview ruler. */ + disableOverviewRuler?: boolean; } /** @@ -230,7 +233,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach scrollSensitivity: config.mouseWheelScrollSensitivity, scrollOnEraseInDisplay: true, wordSeparator: config.wordSeparators, - overviewRuler: { + overviewRuler: options.disableOverviewRuler ? { width: 0 } : { width: 14, showTopBorder: true, }, @@ -531,10 +534,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this.raw.options.customGlyphs = config.customGlyphs; this.raw.options.ignoreBracketedPasteMode = config.ignoreBracketedPasteMode; this.raw.options.rescaleOverlappingGlyphs = config.rescaleOverlappingGlyphs; - this.raw.options.overviewRuler = { - width: 14, - showTopBorder: true, - }; + this._updateSmoothScrolling(); if (this._attached) { if (this._attached.options.enableGpu) { @@ -891,6 +891,27 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this._onDidRequestRefreshDimensions.fire(); } + 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) { + end = end - 1; + } + return this._serializeAddon.serialize({ + range: { + start: startMarker.line, + end: end + } + }); + } + + getXtermTheme(theme?: IColorTheme): ITheme { if (!theme) { theme = this._themeService.getColorTheme(); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 96998dddc45a1..0abd4b0f8020c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -566,7 +566,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { throw new CancellationError(); } - await this._commandArtifactCollector.capture(toolSpecificData, toolTerminal.instance, commandId, pollingResult?.output); + await this._commandArtifactCollector.capture(toolSpecificData, toolTerminal.instance, commandId); const state = toolSpecificData.terminalCommandState ?? {}; state.timestamp = state.timestamp ?? timingStart; toolSpecificData.terminalCommandState = state; @@ -665,7 +665,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { throw new CancellationError(); } - await this._commandArtifactCollector.capture(toolSpecificData, toolTerminal.instance, commandId, executeResult.output); + await this._commandArtifactCollector.capture(toolSpecificData, toolTerminal.instance, commandId); { const state = toolSpecificData.terminalCommandState ?? {}; state.timestamp = state.timestamp ?? timingStart; 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..878ef7b9516a0 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalCommandArtifactCollector.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/terminalCommandArtifactCollector.ts @@ -5,7 +5,6 @@ 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 { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; @@ -19,7 +18,6 @@ export class TerminalCommandArtifactCollector { toolSpecificData: IChatTerminalToolInvocationData, instance: ITerminalInstance, commandId: string | undefined, - fallbackOutput?: string ): Promise { if (commandId) { try { @@ -28,24 +26,19 @@ export class TerminalCommandArtifactCollector { 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 = await this._tryGetCommand(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 +54,8 @@ 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 async _tryGetCommand(instance: ITerminalInstance, commandId: string) { 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 commandDetection?.commands.find(c => c.id === commandId); } }