diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTypeAheadAddon.ts b/src/vs/workbench/contrib/terminal/browser/terminalTypeAheadAddon.ts index bde39712d1828269f01404852d76f846322cb3a3..97ccdd735516ff0732c07dfec9e1cc22ae9a44de 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTypeAheadAddon.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTypeAheadAddon.ts @@ -9,6 +9,7 @@ import { Emitter } from 'vs/base/common/event'; import { Disposable } from 'vs/base/common/lifecycle'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { TerminalConfigHelper } from 'vs/workbench/contrib/terminal/browser/terminalConfigHelper'; +import { XTermAttributes, XTermCore } from 'vs/workbench/contrib/terminal/browser/xterm-private'; import { IBeforeProcessDataEvent, ITerminalConfiguration, ITerminalProcessManager } from 'vs/workbench/contrib/terminal/common/terminal'; import type { IBuffer, IBufferCell, ITerminalAddon, Terminal } from 'xterm'; @@ -37,6 +38,9 @@ const statsToggleOffThreshold = 0.5; // if latency is less than `threshold * thi */ const PREDICTION_OMIT_RE = /^(\x1b\[\??25[hl])+/; +const core = (terminal: Terminal): XTermCore => (terminal as any)._core; +const flushOutput = (terminal: Terminal) => core(terminal).writeSync(''); + const enum CursorMoveDirection { Back = 'D', Forwards = 'C', @@ -156,6 +160,13 @@ const enum MatchResult { } export interface IPrediction { + /** + * Whether applying this prediction can modify the style attributes of the + * terminal. If so it means we need to reset the cursor style if it's + * rolled back. + */ + readonly affectsStyle?: boolean; + /** * Returns a sequence to apply the prediction. * @param buffer to write to @@ -329,22 +340,25 @@ class TentativeBoundary implements IPrediction { * Prediction for a single alphanumeric character. */ class CharacterPrediction implements IPrediction { + public readonly affectsStyle = true; + protected appliedAt?: { - pos: ICoordinate, + pos: ICoordinate; oldAttributes: string; oldChar: string; }; - constructor(private readonly style: string, private readonly char: string) { } + constructor(private readonly style: TypeAheadStyle, private readonly char: string) { } - public apply(buffer: IBuffer, cursor: Cursor) { + public apply(_: IBuffer, cursor: Cursor) { const cell = cursor.getCell(); this.appliedAt = cell - ? { pos: cursor.coordinate, oldAttributes: getBufferCellAttributes(cell), oldChar: cell.getChars() } + ? { pos: cursor.coordinate, oldAttributes: attributesToSeq(cell), oldChar: cell.getChars() } : { pos: cursor.coordinate, oldAttributes: '', oldChar: '' }; cursor.shift(1); - return this.style + this.char + this.appliedAt.oldAttributes; + + return this.style.apply + this.char + this.style.undo; } public rollback(cursor: Cursor) { @@ -362,7 +376,7 @@ class CharacterPrediction implements IPrediction { return ''; // not applied } - return cursor.clone().moveTo(this.appliedAt.pos) + this.appliedAt.oldAttributes + input; + return cursor.clone().moveTo(this.appliedAt.pos) + input; } public matches(input: StringReader) { @@ -384,20 +398,32 @@ class CharacterPrediction implements IPrediction { } } -class BackspacePrediction extends CharacterPrediction { - constructor() { - super('', '\b'); - } +class BackspacePrediction implements IPrediction { + protected appliedAt?: { + pos: ICoordinate; + oldAttributes: string; + oldChar: string; + }; public apply(_: IBuffer, cursor: Cursor) { const cell = cursor.getCell(); this.appliedAt = cell - ? { pos: cursor.coordinate, oldAttributes: getBufferCellAttributes(cell), oldChar: cell.getChars() } + ? { pos: cursor.coordinate, oldAttributes: attributesToSeq(cell), oldChar: cell.getChars() } : { pos: cursor.coordinate, oldAttributes: '', oldChar: '' }; return cursor.shift(-1) + DELETE_CHAR; } + public rollback(cursor: Cursor) { + if (!this.appliedAt) { + return ''; // not applied + } + + const { oldAttributes, oldChar, pos } = this.appliedAt; + const r = cursor.moveTo(pos) + (oldChar ? `${oldAttributes}${oldChar}${cursor.moveTo(pos)}` : DELETE_CHAR); + return r; + } + public rollForwards() { return ''; } @@ -483,7 +509,7 @@ class CursorMovePrediction implements IPrediction { public apply(buffer: IBuffer, cursor: Cursor) { const prevPosition = cursor.x; const currentCell = cursor.getCell(); - const prevAttrs = currentCell ? getBufferCellAttributes(currentCell) : ''; + const prevAttrs = currentCell ? attributesToSeq(currentCell) : ''; const { amount, direction, moveByWords } = this; const delta = direction === CursorMoveDirection.Back ? -1 : 1; @@ -650,7 +676,11 @@ export class PredictionTimeline { private readonly succeededEmitter = new Emitter(); public readonly onPredictionSucceeded = this.succeededEmitter.event; - constructor(public readonly terminal: Terminal) { } + public get isShowingPredictions() { + return this.showPredictions; + } + + constructor(public readonly terminal: Terminal, private readonly style: TypeAheadStyle) { } public setShowPredictions(show: boolean) { if (show === this.showPredictions) { @@ -668,6 +698,7 @@ export class PredictionTimeline { const toApply = this.expected.filter(({ gen }) => gen === this.expected[0].gen).map(({ p }) => p); if (show) { this.cursor = undefined; + this.style.expectIncomingStyle(toApply.reduce((count, p) => p.affectsStyle ? count + 1 : count, 0)); this.terminal.write(toApply.map(p => p.apply(buffer, this.getCursor(buffer))).join('')); } else { this.terminal.write(toApply.reverse().map(p => p.rollback(this.getCursor(buffer))).join('')); @@ -729,10 +760,13 @@ export class PredictionTimeline { case MatchResult.Failure: // on a failure, roll back all remaining items in this generation // and clear predictions, since they are no longer valid - output += this.expected.filter(p => p.gen === startingGen) - .reverse() - .map(({ p }) => p.rollback(this.getCursor(buffer))) - .join(''); + const rollback = this.expected.filter(p => p.gen === startingGen).reverse(); + output += rollback.map(({ p }) => p.rollback(this.getCursor(buffer))).join(''); + if (rollback.some(r => r.p.affectsStyle)) { + // reading the current style should generally be safe, since predictions + // always restore the style if they modify it. + output += attributesToSeq(core(this.terminal)._inputHandler._curAttrData); + } this.expected = []; this.cursor = undefined; this.failedEmitter.fire(prediction); @@ -756,6 +790,9 @@ export class PredictionTimeline { if (gen !== this.expected[0].gen) { break; } + if (p.affectsStyle) { + this.style.expectIncomingStyle(); + } output += p.apply(buffer, this.getCursor(buffer)); } @@ -788,7 +825,10 @@ export class PredictionTimeline { if (this.currentGen === this.expected[0].gen) { const text = prediction.apply(buffer, this.getCursor(buffer)); - if (this.showPredictions) { + if (this.showPredictions && text) { + if (prediction.affectsStyle) { + this.style.expectIncomingStyle(); + } // console.log('predict:', JSON.stringify(text)); this.terminal.write(text); } @@ -804,13 +844,27 @@ export class PredictionTimeline { * after this one will only be displayed after the give prediction matches * pty output/ */ - public addBoundary(buffer: IBuffer, prediction: IPrediction) { - this.addPrediction(buffer, prediction); + public addBoundary(): void; + public addBoundary(buffer: IBuffer, prediction: IPrediction): void; + public addBoundary(buffer?: IBuffer, prediction?: IPrediction) { + if (buffer && prediction) { + this.addPrediction(buffer, prediction); + } this.currentGen++; } + /** + * Peeks the last prediction written. + */ + public peekEnd() { + return this.expected[this.expected.length - 1]?.p; + } + public getCursor(buffer: IBuffer) { if (!this.cursor) { + if (this.showPredictions) { + flushOutput(this.terminal); + } this.cursor = new Cursor(this.terminal.rows, this.terminal.cols, buffer); } @@ -829,7 +883,7 @@ export class PredictionTimeline { /** * Gets the escape sequence to restore state/appearence in the cell. */ -const getBufferCellAttributes = (cell: IBufferCell) => cell.isAttributeDefault() +const attributesToSeq = (cell: XTermAttributes) => cell.isAttributeDefault() ? `${CSI}0m` : [ cell.isBold() && `${CSI}1m`, @@ -849,26 +903,179 @@ const getBufferCellAttributes = (cell: IBufferCell) => cell.isAttributeDefault() cell.isBgDefault() && `${CSI}49m`, ].filter(seq => !!seq).join(''); -const parseTypeheadStyle = (style: ITerminalConfiguration['typeaheadStyle']) => { - switch (style) { - case 'bold': - return `${CSI}1m`; - case 'dim': - return `${CSI}2m`; - case 'italic': - return `${CSI}3m`; - case 'underlined': - return `${CSI}4m`; - case 'inverted': - return `${CSI}7m`; - default: - const { r, g, b } = Color.fromHex(style).rgba; - return `${CSI}38;2;${r};${g};${b}m`; + +const arrayHasPrefixAt = (a: ReadonlyArray, ai: number, b: ReadonlyArray) => { + if (a.length - ai > b.length) { + return false; + } + + for (let bi = 0; bi < b.length; bi++, ai++) { + if (b[ai] !== a[ai]) { + return false; + } } + + return true; }; +/** + * @see https://github.com/xtermjs/xterm.js/blob/065eb13a9d3145bea687239680ec9696d9112b8e/src/common/InputHandler.ts#L2127 + */ +const getColorWidth = (params: (number | number[])[], pos: number) => { + const accu = [0, 0, -1, 0, 0, 0]; + let cSpace = 0; + let advance = 0; + + do { + const v = params[pos + advance]; + accu[advance + cSpace] = typeof v === 'number' ? v : v[0]; + if (typeof v !== 'number') { + let i = 0; + do { + if (accu[1] === 5) { + cSpace = 1; + } + accu[advance + i + 1 + cSpace] = v[i]; + } while (++i < v.length && i + advance + 1 + cSpace < accu.length); + break; + } + // exit early if can decide color mode with semicolons + if ((accu[1] === 5 && advance + cSpace >= 2) + || (accu[1] === 2 && advance + cSpace >= 5)) { + break; + } + // offset colorSpace slot for semicolon mode + if (accu[1]) { + cSpace = 1; + } + } while (++advance + pos < params.length && advance + cSpace < accu.length); + + return advance; +}; + +class TypeAheadStyle { + private static compileArgs(args: ReadonlyArray) { + return `${CSI}${args.join(';')}m`; + } + + /** + * Number of typeahead style arguments we expect to read. If this is 0 and + * we see a style coming in, we know that the PTY actually wanted to update. + */ + private expectedIncomingStyles = 0; + private applyArgs!: ReadonlyArray; + private originalUndoArgs!: ReadonlyArray; + private undoArgs!: ReadonlyArray; + + public apply!: string; + public undo!: string; + + constructor(value: ITerminalConfiguration['typeaheadStyle']) { + this.onUpdate(value); + } + + /** + * Signals that a style was written to the terminal and we should watch + * for it coming in. + */ + public expectIncomingStyle(n = 1) { + this.expectedIncomingStyles += n * 2; + } + + /** + * Should be called when an attribut eupdate happens in the terminal. + */ + public onDidWriteSGR(args: (number | number[])[]) { + const originalUndo = this.undoArgs; + for (let i = 0; i < args.length;) { + const px = args[i]; + const p = typeof px === 'number' ? px : px[0]; + + if (this.expectedIncomingStyles) { + if (arrayHasPrefixAt(args, i, this.undoArgs)) { + this.expectedIncomingStyles--; + i += this.undoArgs.length; + continue; + } + if (arrayHasPrefixAt(args, i, this.applyArgs)) { + this.expectedIncomingStyles--; + i += this.applyArgs.length; + continue; + } + } + + const width = p === 38 || p === 48 || p === 58 ? getColorWidth(args, i) : 1; + switch (this.applyArgs[0]) { + case 1: + if (p === 2) { + this.undoArgs = [22, 2]; + } else if (p === 22 || p === 0) { + this.undoArgs = [22]; + } + break; + case 2: + if (p === 1) { + this.undoArgs = [22, 1]; + } else if (p === 22 || p === 0) { + this.undoArgs = [22]; + } + break; + case 38: + if (p === 0 || p === 39 || p === 100) { + this.undoArgs = [39]; + } else if ((p >= 30 && p <= 38) || (p >= 90 && p <= 97)) { + this.undoArgs = args.slice(i, i + width) as number[]; + } + break; + default: + if (p === this.applyArgs[0]) { + this.undoArgs = this.applyArgs; + } else if (p === 0) { + this.undoArgs = this.originalUndoArgs; + } + // no-op + } + + i += width; + } + + if (originalUndo !== this.undoArgs) { + this.undo = TypeAheadStyle.compileArgs(this.undoArgs); + } + } + + /** + * Updates the current typeahead style. + */ + public onUpdate(style: ITerminalConfiguration['typeaheadStyle']) { + const { applyArgs, undoArgs } = this.getArgs(style); + this.applyArgs = applyArgs; + this.undoArgs = this.originalUndoArgs = undoArgs; + this.apply = TypeAheadStyle.compileArgs(this.applyArgs); + this.undo = TypeAheadStyle.compileArgs(this.undoArgs); + } + + private getArgs(style: ITerminalConfiguration['typeaheadStyle']) { + switch (style) { + case 'bold': + return { applyArgs: [1], undoArgs: [22] }; + case 'dim': + return { applyArgs: [2], undoArgs: [22] }; + case 'italic': + return { applyArgs: [3], undoArgs: [23] }; + case 'underlined': + return { applyArgs: [4], undoArgs: [24] }; + case 'inverted': + return { applyArgs: [7], undoArgs: [27] }; + default: + const { r, g, b } = Color.fromHex(style).rgba; + return { applyArgs: [38, 2, r, g, b], undoArgs: [39] }; + } + } +} + export class TypeAheadAddon extends Disposable implements ITerminalAddon { - private typeheadStyle = parseTypeheadStyle(this.config.config.typeaheadStyle); + private typeaheadStyle = new TypeAheadStyle(this.config.config.typeaheadStyle); private typeaheadThreshold = this.config.config.typeaheadThreshold; private lastRow?: { y: number; startingX: number }; private timeline?: PredictionTimeline; @@ -883,10 +1090,14 @@ export class TypeAheadAddon extends Disposable implements ITerminalAddon { } public activate(terminal: Terminal): void { - const timeline = this.timeline = new PredictionTimeline(terminal); + const timeline = this.timeline = new PredictionTimeline(terminal, this.typeaheadStyle); const stats = this.stats = this._register(new PredictionStats(this.timeline)); timeline.setShowPredictions(this.typeaheadThreshold === 0); + this._register(terminal.parser.registerCsiHandler({ final: 'm' }, args => { + this.typeaheadStyle.onDidWriteSGR(args); + return false; + })); this._register(terminal.onData(e => this.onUserData(e))); this._register(terminal.onResize(() => { timeline.setShowPredictions(false); @@ -894,7 +1105,7 @@ export class TypeAheadAddon extends Disposable implements ITerminalAddon { this.reevaluatePredictorState(stats, timeline); })); this._register(this.config.onConfigChanged(() => { - this.typeheadStyle = parseTypeheadStyle(this.config.config.typeaheadStyle); + this.typeaheadStyle.onUpdate(this.config.config.typeaheadStyle); this.typeaheadThreshold = this.config.config.typeaheadThreshold; this.reevaluatePredictorState(stats, timeline); })); @@ -989,13 +1200,24 @@ export class TypeAheadAddon extends Disposable implements ITerminalAddon { const reader = new StringReader(data); while (reader.remaining > 0) { if (reader.eatCharCode(127)) { // backspace + const previous = this.timeline.peekEnd(); + if (previous && previous instanceof CharacterPrediction) { + this.timeline.addBoundary(); + } + + // backspace must be able to read the previously-written character in + // the event that it needs to undo it + if (this.timeline.isShowingPredictions) { + flushOutput(this.timeline.terminal); + } + addLeftNavigating(new BackspacePrediction()); continue; } if (reader.eatCharCode(32, 126)) { // alphanum const char = data[reader.index - 1]; - if (this.timeline.addPrediction(buffer, new CharacterPrediction(this.typeheadStyle, char)) && this.timeline.getCursor(buffer).x === terminal.cols) { + if (this.timeline.addPrediction(buffer, new CharacterPrediction(this.typeaheadStyle, char)) && this.timeline.getCursor(buffer).x === terminal.cols) { this.timeline.addBoundary(buffer, new TentativeBoundary(new LinewrapPrediction())); } continue; diff --git a/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts b/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts index 3ae0210352dfc7ec2ea9cff6720e242373cb4e91..9a1829b48c81acde0eccf1032ca94c48756181e7 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm-private.d.ts @@ -3,6 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IBufferCell } from 'xterm'; + +export type XTermAttributes = Omit & { clone?(): XTermAttributes }; + export interface XTermCore { _onScroll: IEventEmitter; _onKey: IEventEmitter<{ key: string }>; @@ -16,6 +20,10 @@ export interface XTermCore { triggerDataEvent(data: string, wasUserInput?: boolean): void; }; + _inputHandler: { + _curAttrData: XTermAttributes; + }; + _renderService: { dimensions: { actualCellWidth: number; diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalTypeahead.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalTypeahead.test.ts index 01085ca16bbfd17ce970ffdb71876e6dde077979..469aed592f5e0016ad368e36bb5a2f25500ab73d 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalTypeahead.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalTypeahead.test.ts @@ -77,7 +77,7 @@ suite('Workbench - Terminal Typeahead', () => { const predictedHelloo = [ `${CSI}?25l`, // hide cursor - `${CSI}2;7H`, // move cursor cursor + `${CSI}2;7H`, // move cursor 'o', // new character `${CSI}2;8H`, // place cursor back at end of line `${CSI}?25h`, // show cursor @@ -107,14 +107,14 @@ suite('Workbench - Terminal Typeahead', () => { }); test('predicts a single character', () => { - const t = createMockTerminal('hello|'); + const t = createMockTerminal({ lines: ['hello|'] }); addon.activate(t.terminal); t.onData('o'); - t.expectWritten(`${CSI}3mo`); + t.expectWritten(`${CSI}3mo${CSI}23m`); }); test('validates character prediction', () => { - const t = createMockTerminal('hello|'); + const t = createMockTerminal({ lines: ['hello|'] }); addon.activate(t.terminal); t.onData('o'); expectProcessed('o', predictedHelloo); @@ -122,7 +122,7 @@ suite('Workbench - Terminal Typeahead', () => { }); test('rolls back character prediction', () => { - const t = createMockTerminal('hello|'); + const t = createMockTerminal({ lines: ['hello|'] }); addon.activate(t.terminal); t.onData('o'); @@ -130,6 +130,27 @@ suite('Workbench - Terminal Typeahead', () => { `${CSI}?25l`, // hide cursor `${CSI}2;7H`, // move cursor cursor `${CSI}X`, // delete character + `${CSI}0m`, // reset style + 'q', // new character + `${CSI}?25h`, // show cursor + ].join('')); + assert.strictEqual(addon.stats?.accuracy, 0); + }); + + test('restores cursor graphics mode', () => { + const t = createMockTerminal({ + lines: ['hello|'], + cursorAttrs: { isAttributeDefault: false, isBold: true, isFgPalette: true, getFgColor: 1 }, + }); + addon.activate(t.terminal); + t.onData('o'); + + expectProcessed('q', [ + `${CSI}?25l`, // hide cursor + `${CSI}2;7H`, // move cursor cursor + `${CSI}X`, // delete character + `${CSI}1m`, // reset style + `${CSI}38;5;1m`, // reset style 'q', // new character `${CSI}?25h`, // show cursor ].join('')); @@ -137,13 +158,13 @@ suite('Workbench - Terminal Typeahead', () => { }); test('validates against and applies graphics mode on predicted', () => { - const t = createMockTerminal('hello|'); + const t = createMockTerminal({ lines: ['hello|'] }); addon.activate(t.terminal); t.onData('o'); expectProcessed(`${CSI}4mo`, [ `${CSI}?25l`, // hide cursor - `${CSI}2;7H`, // move cursor cursor - `${CSI}4m`, // PTY's style + `${CSI}2;7H`, // move cursor + `${CSI}4m`, // new PTY's style 'o', // new character `${CSI}2;8H`, // place cursor back at end of line `${CSI}?25h`, // show cursor @@ -152,13 +173,13 @@ suite('Workbench - Terminal Typeahead', () => { }); test('ignores cursor hides or shows', () => { - const t = createMockTerminal('hello|'); + const t = createMockTerminal({ lines: ['hello|'] }); addon.activate(t.terminal); t.onData('o'); expectProcessed(`${CSI}?25lo${CSI}?25h`, [ `${CSI}?25l`, // hide cursor from PTY `${CSI}?25l`, // hide cursor - `${CSI}2;7H`, // move cursor cursor + `${CSI}2;7H`, // move cursor 'o', // new character `${CSI}?25h`, // show cursor from PTY `${CSI}2;8H`, // place cursor back at end of line @@ -168,7 +189,7 @@ suite('Workbench - Terminal Typeahead', () => { }); test('matches backspace at EOL (bash style)', () => { - const t = createMockTerminal('hello|'); + const t = createMockTerminal({ lines: ['hello|'] }); addon.activate(t.terminal); t.onData('\x7F'); expectProcessed(`\b${CSI}K`, `\b${CSI}K`); @@ -176,7 +197,7 @@ suite('Workbench - Terminal Typeahead', () => { }); test('matches backspace at EOL (zsh style)', () => { - const t = createMockTerminal('hello|'); + const t = createMockTerminal({ lines: ['hello|'] }); addon.activate(t.terminal); t.onData('\x7F'); expectProcessed('\b \b', '\b \b'); @@ -184,7 +205,7 @@ suite('Workbench - Terminal Typeahead', () => { }); test('gradually matches backspace', () => { - const t = createMockTerminal('hello|'); + const t = createMockTerminal({ lines: ['hello|'] }); addon.activate(t.terminal); t.onData('\x7F'); expectProcessed('\b', ''); @@ -193,7 +214,7 @@ suite('Workbench - Terminal Typeahead', () => { }); test('waits for validation before deleting to left of cursor', () => { - const t = createMockTerminal('hello|'); + const t = createMockTerminal({ lines: ['hello|'] }); addon.activate(t.terminal); // initially should not backspace (until the server confirms it) @@ -214,7 +235,7 @@ suite('Workbench - Terminal Typeahead', () => { }); test('avoids predicting password input', () => { - const t = createMockTerminal('hello|'); + const t = createMockTerminal({ lines: ['hello|'] }); addon.activate(t.terminal); expectProcessed('Your password: ', 'Your password: '); @@ -223,7 +244,7 @@ suite('Workbench - Terminal Typeahead', () => { expectProcessed('\r\n', '\r\n'); t.onData('o'); // back to normal mode - t.expectWritten(`${CSI}3mo`); + t.expectWritten(`${CSI}3mo${CSI}23m`); }); }); }); @@ -245,10 +266,14 @@ function stubPrediction(): IPrediction { }; } -function createMockTerminal(...lines: string[]) { +function createMockTerminal({ lines, cursorAttrs }: { + lines: string[], + cursorAttrs?: any, +}) { const written: string[] = []; const cursor = { y: 1, x: 1 }; const onData = new Emitter(); + const csiEmitter = new Emitter(); for (let y = 0; y < lines.length; y++) { const line = lines[y]; @@ -269,14 +294,28 @@ function createMockTerminal(...lines: string[]) { }, clearWritten: () => written.splice(0, written.length), onData: (s: string) => onData.fire(s), + csiEmitter, terminal: { cols: 80, rows: 5, onResize: new Emitter().event, onData: onData.event, + parser: { + registerCsiHandler(_: unknown, callback: () => void) { + csiEmitter.event(callback); + }, + }, write(line: string) { written.push(line); }, + _core: { + _inputHandler: { + _curAttrData: mockCell('', cursorAttrs) + }, + writeSync() { + + } + }, buffer: { active: { type: 'normal', @@ -296,9 +335,13 @@ function createMockTerminal(...lines: string[]) { }; } -function mockCell(char: string) { +function mockCell(char: string, attrs: { [key: string]: unknown } = {}) { return new Proxy({}, { get(_, prop) { + if (typeof prop === 'string' && attrs.hasOwnProperty(prop)) { + return () => attrs[prop]; + } + switch (prop) { case 'getWidth': return () => 1; @@ -306,6 +349,8 @@ function mockCell(char: string) { return () => char; case 'getCode': return () => char.charCodeAt(0) || 0; + case 'isAttributeDefault': + return () => true; default: return String(prop).startsWith('is') ? (() => false) : (() => 0); }