Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -151,16 +151,21 @@
.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-container .xterm-decoration-overview-ruler {
display: none !important;
}
.chat-terminal-output {
margin: 0;
Expand Down

Large diffs are not rendered by default.

268 changes: 268 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable, DisposableStore, ImmortalReference } from '../../../../base/common/lifecycle.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import type { ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js';
import { ITerminalInstance, ITerminalService, type IDetachedTerminalInstance } from './terminal.js';
import { DetachedProcessInfo } from './detachedTerminal.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { TerminalInstanceColorProvider } from './terminalInstance.js';
import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js';
import type { Terminal as RawXtermTerminal } from '@xterm/xterm';
import { ICurrentPartialCommand } from '../../../../platform/terminal/common/capabilities/commandDetection/terminalCommand.js';

interface IDetachedTerminalCommandMirror {
attach(container: HTMLElement): Promise<void>;
renderCommand(): Promise<{ lineCount?: number } | undefined>;
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The IDetachedTerminalCommandMirror interface is incomplete - it's missing the onDidUpdate event which is a public member of DetachedTerminalCommandMirror. Since the interface is not exported and only used internally, consider either:

  1. Adding onDidUpdate to the interface for completeness
  2. Removing the interface entirely if it's not providing value

This ensures the interface accurately represents the public API of the class.

Suggested change
renderCommand(): Promise<{ lineCount?: number } | undefined>;
renderCommand(): Promise<{ lineCount?: number } | undefined>;
onDidUpdate: Event<number>;

Copilot uses AI. Check for mistakes.
}

/**
* 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?: IDetachedTerminalInstance;
private _attachedContainer?: HTMLElement;
private readonly _streamingDisposables = this._register(new DisposableStore());
private readonly _onDidUpdateEmitter = this._register(new Emitter<number>());
public readonly onDidUpdate: Event<number> = this._onDidUpdateEmitter.event;

private _lastVT = '';
private _lineCount = 0;
private _lastUpToDateCursorY: number | undefined;
private _lowestDirtyCursorY: number | undefined;
private _highestDirtyCursorY: number | undefined;
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _highestDirtyCursorY field is tracked but never read. It's set in _handleCursorEvent() and cleared in _doFlushDirtyRange(), but its value is not used anywhere. Consider either removing it if it's not needed, or documenting why it's being tracked for future use.

Copilot uses AI. Check for mistakes.
private _flushPromise: Promise<void> | undefined;
private _dirtyScheduled = false;
private _hasInitialized = false;
private _isStreaming = false;
private _sourceRaw: RawXtermTerminal | undefined;

constructor(
private readonly _terminalInstance: ITerminalInstance,
private readonly _command: ITerminalCommand,
@ITerminalService private readonly _terminalService: ITerminalService,
@IInstantiationService private readonly _instantationService: IInstantiationService
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in parameter name: _instantationService should be _instantiationService (missing 'i').

Suggested change
@IInstantiationService private readonly _instantationService: IInstantiationService
@IInstantiationService private readonly _instantiationService: IInstantiationService

Copilot uses AI. Check for mistakes.
) {
super();
}

override dispose(): void {
this._stopStreaming();
super.dispose();
}

async attach(container: HTMLElement): Promise<void> {
const terminal = await this._getOrCreateTerminal();
if (this._attachedContainer !== container) {
container.classList.add('chat-terminal-output-terminal');
terminal.attachToElement(container);
this._attachedContainer = container;
}
}

async renderCommand(): Promise<{ lineCount?: number } | undefined> {
const detached = await this._getOrCreateTerminal();
const detachedRaw = detached.xterm.raw;
if (!detachedRaw) {
return undefined;
}
let vt;
try {
vt = await this._getCommandOutputAsVT();
} catch {

Comment on lines +76 to +77
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty catch block silently swallows errors. Consider logging the error or at least adding a comment explaining why it's safe to ignore errors here.

Suggested change
} catch {
} catch (error) {
console.error('Error in renderCommand _getCommandOutputAsVT:', error);

Copilot uses AI. Check for mistakes.
}
if (!vt) {
return undefined;
}

if (!this._hasInitialized) {
detachedRaw.reset();
this._hasInitialized = true;
}

const shouldRewrite = !this._lastVT || !vt.text.startsWith(this._lastVT);
if (shouldRewrite) {
detachedRaw.reset();
if (vt.text) {
detachedRaw.write(vt.text);
}
} else {
const appended = vt.text.slice(this._lastVT.length);
if (appended) {
detachedRaw.write(appended);
}
}

detachedRaw.scrollToBottom();
detachedRaw.refresh(0, detachedRaw.rows - 1);

this._lastVT = vt.text;
this._lineCount = vt.lineCount;

const xterm = await this._terminalInstance.xtermReadyPromise;
const sourceRaw = xterm?.raw;
if (sourceRaw) {
this._sourceRaw = sourceRaw;
this._lastUpToDateCursorY = this._getAbsoluteCursorY(sourceRaw);
if (!this._isStreaming && (!this._command.endMarker || this._command.endMarker.isDisposed)) {
this._startStreaming(sourceRaw);
}
}

return { lineCount: vt.lineCount };
}

private async _getCommandOutputAsVT(): Promise<{ text: string; lineCount: number } | undefined> {
const executedMarker = this._command.executedMarker ?? (this._command as unknown as ICurrentPartialCommand).commandExecutedMarker;
if (!executedMarker || executedMarker.isDisposed) {
return undefined;
}

const xterm = await this._terminalInstance.xtermReadyPromise;
if (!xterm) {
return undefined;
}

const endMarker = this._command.endMarker;
const text = await xterm.getRangeAsVT(executedMarker, endMarker, endMarker?.line !== executedMarker.line);
if (!text) {
return { text: '', lineCount: 0 };
}

return { text, lineCount: text.split('\r\n').length };
}

private async _getOrCreateTerminal(): Promise<IDetachedTerminalInstance> {
if (this._detachedTerminal) {
return this._detachedTerminal;
}
const targetRef = this._terminalInstance?.targetRef ?? new ImmortalReference<TerminalLocation | undefined>(undefined);
const colorProvider = this._instantationService.createInstance(TerminalInstanceColorProvider, targetRef);
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in variable name: _instantationService should be _instantiationService (missing 'i').

Copilot uses AI. Check for mistakes.
const detached = await this._terminalService.createDetachedTerminal({
cols: this._terminalInstance?.cols ?? 80,
rows: 10,
readonly: true,
processInfo: new DetachedProcessInfo({ initialCwd: '' }),
colorProvider
});
this._detachedTerminal = detached;
this._register(detached);
return detached;
}

private _startStreaming(raw: RawXtermTerminal): void {
if (this._isStreaming) {
return;
}
this._isStreaming = true;
this._streamingDisposables.add(Event.any(raw.onCursorMove, raw.onLineFeed, raw.onWriteParsed)(() => this._handleCursorEvent()));
this._streamingDisposables.add(this._terminalInstance.onData(() => this._handleCursorEvent()));
}

private _stopStreaming(): void {
if (!this._isStreaming) {
return;
}
this._streamingDisposables.clear();
this._isStreaming = false;
this._lowestDirtyCursorY = undefined;
this._highestDirtyCursorY = undefined;
}

private _handleCursorEvent(): void {
if (!this._sourceRaw) {
return;
}
const cursorY = this._getAbsoluteCursorY(this._sourceRaw);
this._lowestDirtyCursorY = this._lowestDirtyCursorY === undefined ? cursorY : Math.min(this._lowestDirtyCursorY, cursorY);
this._highestDirtyCursorY = this._highestDirtyCursorY === undefined ? cursorY : Math.max(this._highestDirtyCursorY, cursorY);
this._scheduleFlush();
}

private _scheduleFlush(): void {
if (this._dirtyScheduled) {
return;
}
this._dirtyScheduled = true;
Promise.resolve().then(() => {
this._dirtyScheduled = false;
this._flushDirtyRange();
});
}

private _flushDirtyRange(): void {
if (this._flushPromise) {
return;
}
this._flushPromise = this._doFlushDirtyRange().finally(() => {
this._flushPromise = undefined;
});
}

private async _doFlushDirtyRange(): Promise<void> {
const xterm = await this._terminalInstance.xtermReadyPromise;
const sourceRaw = xterm?.raw;
const detached = this._detachedTerminal ?? await this._getOrCreateTerminal();
const detachedRaw = detached.xterm.raw;
if (!sourceRaw || !detachedRaw) {
return;
}

this._sourceRaw = sourceRaw;
const currentCursor = this._getAbsoluteCursorY(sourceRaw);
const previousCursor = this._lastUpToDateCursorY ?? currentCursor;
const startCandidate = this._lowestDirtyCursorY ?? currentCursor;
this._lowestDirtyCursorY = undefined;
this._highestDirtyCursorY = undefined;

const startLine = Math.min(previousCursor, startCandidate);
// Ensure we resolve any pending flush even when no actual new output is available.
const vt = await this._getCommandOutputAsVT();
if (!vt) {
return;
}

if (vt.text === this._lastVT) {
this._lineCount = vt.lineCount;
this._lastUpToDateCursorY = currentCursor;
if (this._command.endMarker && !this._command.endMarker.isDisposed) {
this._stopStreaming();
}
return;
}

const canAppend = !!this._lastVT && startLine >= previousCursor && vt.text.startsWith(this._lastVT);
if (!this._lastVT || !canAppend) {
detachedRaw.reset();
if (vt.text) {
detachedRaw.write(vt.text);
}
} else {
const appended = vt.text.slice(this._lastVT.length);
if (appended) {
detachedRaw.write(appended);
}
}

detachedRaw.scrollToBottom();
detachedRaw.refresh(0, detachedRaw.rows - 1);

this._lastVT = vt.text;
this._lineCount = vt.lineCount;
this._lastUpToDateCursorY = currentCursor;
this._onDidUpdateEmitter.fire(this._lineCount);

if (this._command.endMarker && !this._command.endMarker.isDisposed) {
this._stopStreaming();
}
}

private _getAbsoluteCursorY(raw: RawXtermTerminal): number {
return raw.buffer.active.baseY + raw.buffer.active.cursorY;
}
}
10 changes: 10 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1343,6 +1343,14 @@ export interface IXtermTerminal extends IDisposable {
*/
getFont(): ITerminalFont;

/**
* Gets the content between two markers as VT sequences.
* @param startMarker
* @param endMarker
* @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<string>;

/**
* Gets whether there's any terminal selection.
*/
Expand Down Expand Up @@ -1430,6 +1438,8 @@ export interface IXtermTerminal extends IDisposable {
}

export interface IDetachedXtermTerminal extends IXtermTerminal {
raw?: RawXtermTerminal;

/**
* Writes data to the terminal.
* @param data data to write
Expand Down
22 changes: 22 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -891,6 +892,27 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach
this._onDidRequestRefreshDimensions.fire();
}

async getRangeAsVT(startMarker: IXtermMarker, endMarker?: IXtermMarker, skipLastLine?: boolean): Promise<string> {
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
}
});
}


Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extra blank line should be removed to maintain consistent spacing between methods.

Suggested change

Copilot uses AI. Check for mistakes.
getXtermTheme(theme?: IColorTheme): ITheme {
if (!theme) {
theme = this._themeService.getColorTheme();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading