diff --git a/src/headless/Terminal.ts b/src/headless/Terminal.ts index cb7f56882f..c45ddd113f 100644 --- a/src/headless/Terminal.ts +++ b/src/headless/Terminal.ts @@ -38,6 +38,8 @@ export class Terminal extends CoreTerminal { public readonly onA11yChar = this._onA11yCharEmitter.event; private readonly _onA11yTabEmitter = this._register(new Emitter()); public readonly onA11yTab = this._onA11yTabEmitter.event; + private readonly _onRowChange = this._register(new Emitter<{ start: number, end: number }>()); + public readonly onRowChange = this._onRowChange.event; constructor( options: ITerminalOptions = {} @@ -53,6 +55,11 @@ export class Terminal extends CoreTerminal { this._register(Event.forward(this._inputHandler.onTitleChange, this._onTitleChange)); this._register(Event.forward(this._inputHandler.onA11yChar, this._onA11yCharEmitter)); this._register(Event.forward(this._inputHandler.onA11yTab, this._onA11yTabEmitter)); + this._register(this._inputHandler.onRequestRefreshRows(e => { + if (e) { + this._onRowChange.fire({ start: e.start, end: e.end }); + } + })); } /** diff --git a/src/headless/public/Terminal.test.ts b/src/headless/public/Terminal.test.ts index ea77bdeee3..b6a5e6d373 100644 --- a/src/headless/public/Terminal.test.ts +++ b/src/headless/public/Terminal.test.ts @@ -241,6 +241,92 @@ describe('Headless API Tests', function (): void { await writeSync('\x07'); deepStrictEqual(calls, [true]); }); + + it('onRowChange', async () => { + let callCount = 0; + term.onRowChange(() => callCount++); + strictEqual(callCount, 0); + await writeSync('hello'); + strictEqual(callCount, 1); + await writeSync('\nworld'); + strictEqual(callCount, 2); + }); + + it('onRowChange - cursor positioning', async () => { + term = new Terminal({ rows: 25, cols: 80, allowProposedApi: true }); + const calls: Array<{ start: number, end: number }> = []; + term.onRowChange(e => calls.push(e)); + + // Basic cursor movements + await writeSync('\x1b[10;40Htest'); // Move to middle, write + strictEqual(calls.length, 1); + strictEqual(calls[0].start, 0); + strictEqual(calls[0].end, 9); + + // Multiple cursor movements in one write + calls.length = 0; + await writeSync('\x1b[5;20Ha\x1b[15;60Hb\x1b[20;10Hc'); + strictEqual(calls.length, 1); + strictEqual(calls[0].start, 4); + strictEqual(calls[0].end, 19); + + // Large scrollback buffer test + const termScroll = new Terminal({ rows: 10, cols: 80, scrollback: 1000, allowProposedApi: true }); + const scrollCalls: Array<{ start: number, end: number }> = []; + termScroll.onRowChange(e => scrollCalls.push(e)); + + // Fill and scroll beyond viewport - validate all events + for (let i = 0; i < 30; i++) { + const beforeCount = scrollCalls.length; + await new Promise(resolve => termScroll.write(`Line ${i}\n`, resolve)); + strictEqual(scrollCalls.length, beforeCount + 1); + const event = scrollCalls[scrollCalls.length - 1]; + if (i < 9) { + // Before scrolling: current row + linefeed to next row + strictEqual(event.start, i); + strictEqual(event.end, i + 1); + } else { + // After scrolling starts: entire viewport + strictEqual(event.start, 0); + strictEqual(event.end, 9); + } + } + + // Cursor movement in scrolled terminal + scrollCalls.length = 0; + await new Promise(resolve => termScroll.write('\x1b[5;40HX', resolve)); + strictEqual(scrollCalls.length, 1); + strictEqual(scrollCalls[0].start, 4); + strictEqual(scrollCalls[0].end, 9); + }); + + it('onRowChange - scrolling', async () => { + term = new Terminal({ rows: 3, cols: 5, allowProposedApi: true }); + const calls: Array<{ start: number, end: number }> = []; + term.onRowChange(e => calls.push(e)); + + // Fill terminal + await writeSync('line1\nline2\nline3'); + calls.length = 0; + + // Cause scroll + await writeSync('\nline4'); + strictEqual(calls.length, 1); + strictEqual(calls[0].start, 0); + strictEqual(calls[0].end, 2); + }); + + it('onRowChange - text wrapping', async () => { + term = new Terminal({ rows: 3, cols: 5, allowProposedApi: true }); + const calls: Array<{ start: number, end: number }> = []; + term.onRowChange(e => calls.push(e)); + + // Write text that wraps + await writeSync('verylongtext'); + strictEqual(calls.length, 1); + strictEqual(calls[0].start, 0); + strictEqual(calls[0].end, 2); + }); }); describe('buffer', () => { diff --git a/src/headless/public/Terminal.ts b/src/headless/public/Terminal.ts index 738570c9e7..76b4a32193 100644 --- a/src/headless/public/Terminal.ts +++ b/src/headless/public/Terminal.ts @@ -11,7 +11,7 @@ import { Terminal as TerminalCore } from 'headless/Terminal'; import { AddonManager } from 'common/public/AddonManager'; import { ITerminalOptions } from 'common/Types'; import { Disposable } from 'vs/base/common/lifecycle'; -import type { Event } from 'vs/base/common/event'; +import { Event } from 'vs/base/common/event'; /** * The set of options that only have an effect when set in the Terminal constructor. */ @@ -81,6 +81,10 @@ export class Terminal extends Disposable implements ITerminalApi { public get onScroll(): Event { return this._core.onScroll; } public get onTitleChange(): Event { return this._core.onTitleChange; } public get onWriteParsed(): Event { return this._core.onWriteParsed; } + public get onRowChange(): Event<{ start: number, end: number }> { + this._checkProposedApi(); + return Event.map(this._core.onRowChange, e => ({ start: e.start, end: e.end })); + } public get parser(): IParser { this._checkProposedApi(); @@ -186,7 +190,7 @@ export class Terminal extends Disposable implements ITerminalApi { } public loadAddon(addon: ITerminalAddon): void { // TODO: This could cause issues if the addon calls renderer apis - this._addonManager.loadAddon(this as any, addon); + this._addonManager.loadAddon(this as any, addon as any); } private _verifyIntegers(...values: number[]): void { diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 11d979474b..e52ccd00d8 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -748,6 +748,13 @@ declare module '@xterm/headless' { */ onResize: IEvent<{ cols: number, rows: number }>; + /** + * Adds an event listener for when buffer rows change during parsing. The event + * value contains the range of rows that changed. + * @returns an `IDisposable` to stop listening. + */ + onRowChange: IEvent<{ start: number, end: number }>; + /** * Adds an event listener for when a scroll occurs. The event value is the * new position of the viewport.