terminal: make XtermTerminal support tmux integration

Bug: b/236205389
Change-Id: I05572d59ac03d144ffcdf91cee862b783687248b
Reviewed-on: https://chromium-review.googlesource.com/c/apps/libapps/+/3944190
Tested-by: kokoro <noreply+kokoro@google.com>
Reviewed-by: Joel Hockey <joelhockey@chromium.org>
diff --git a/terminal/js/terminal_common.js b/terminal/js/terminal_common.js
index 5b04cb2..4860b5c 100644
--- a/terminal/js/terminal_common.js
+++ b/terminal/js/terminal_common.js
@@ -463,7 +463,8 @@
 
 /**
  * Return a new "scheduler" function. When the function is called, it will
- * schedule the `callback` to be called after `delay` time. The function does
+ * schedule the `callback` to be called after `delay` time. It also returns a
+ * promise, which is fulfilled after the callback is called. The function does
  * nothing if it is called again and the last one hasn't timed out yet.
  *
  * TODO: This can probably replace some other existing scheduling code (search
@@ -471,17 +472,20 @@
  *
  * @param {function()} callback
  * @param {number} delay
- * @return {function()} The schedule function.
+ * @return {function(): !Promise<void>} The schedule function.
  */
 export function delayedScheduler(callback, delay) {
-  let pending = false;
+  let donePromise = null;
   return () => {
-    if (!pending) {
-      pending = true;
-      setTimeout(() => {
-        pending = false;
-        callback();
-      }, delay);
+    if (!donePromise) {
+      donePromise = new Promise((resolve) => {
+        setTimeout(() => {
+          donePromise = null;
+          callback();
+          resolve();
+        }, delay);
+      });
     }
+    return donePromise;
   };
 }
diff --git a/terminal/js/terminal_common_tests.js b/terminal/js/terminal_common_tests.js
index 67b5c02..d88ee33 100644
--- a/terminal/js/terminal_common_tests.js
+++ b/terminal/js/terminal_common_tests.js
@@ -9,7 +9,7 @@
 import {DEFAULT_BACKGROUND_COLOR, SUPPORTED_FONT_FAMILIES,
   SUPPORTED_FONT_FAMILIES_MINIMAL, delayedScheduler, definePrefs,
   fontFamilyToCSS, getSupportedFontFamilies, normalizeCSSFontFamily,
-  normalizePrefsInPlace, sleep} from './terminal_common.js';
+  normalizePrefsInPlace} from './terminal_common.js';
 
 describe('terminal_common_tests.js', () => {
   beforeEach(function() {
@@ -63,13 +63,14 @@
 
   it('delayedScheduler', async function() {
     let counter = 0;
-    const schedule = delayedScheduler(() => ++counter, 0);
+    const schedule = delayedScheduler(() => ++counter, 50);
+    const promise = schedule();
     for (let i = 0; i < 10; ++i) {
       schedule();
     }
     assert.equal(counter, 0);
 
-    await sleep(0);
+    await promise;
     assert.equal(counter, 1);
   });
 
diff --git a/terminal/js/terminal_emulator.js b/terminal/js/terminal_emulator.js
index aead328..d25a2af 100644
--- a/terminal/js/terminal_emulator.js
+++ b/terminal/js/terminal_emulator.js
@@ -9,6 +9,8 @@
 // TODO(b/236205389): add tests. For example, we should enable the test in
 // terminal_tests.js for XtermTerminal.
 
+// TODO(b/236205389): support option smoothScrollDuration?
+
 import {LitElement, css, html} from './lit.js';
 import {FontManager, ORIGINAL_URL, TERMINAL_EMULATORS, delayedScheduler,
   fontManager, getOSInfo, sleep} from './terminal_common.js';
@@ -16,6 +18,7 @@
 import {TerminalTooltip} from './terminal_tooltip.js';
 import {Terminal, Unicode11Addon, WebLinksAddon, WebglAddon}
     from './xterm.js';
+import {XtermInternal} from './terminal_xterm_internal.js';
 
 
 /** @enum {number} */
@@ -82,6 +85,7 @@
  * @typedef {{
  *   term: !Terminal,
  *   fontManager: !FontManager,
+ *   xtermInternal: !XtermInternal,
  * }}
  */
 export let XtermTerminalTestParams;
@@ -264,6 +268,8 @@
 
     // TODO: we should probably pass the initial prefs to the ctor.
     this.term = testParams?.term || new Terminal({allowProposedApi: true});
+    this.xtermInternal_ = testParams?.xtermInternal ||
+        new XtermInternal(this.term);
     this.fontManager_ = testParams?.fontManager || fontManager;
 
     /** @type {?Element} */
@@ -291,7 +297,7 @@
     // prompt, which only listens to onVTKeystroke().
     this.term.onData((data) => this.io.onVTKeystroke(data));
     this.term.onBinary((data) => this.io.onVTKeystroke(data));
-    this.term.onTitleChange((title) => document.title = title);
+    this.term.onTitleChange((title) => this.setWindowTitle(title));
     this.term.onSelectionChange(() => this.copySelection_());
     this.term.onBell(() => this.ringBell());
 
@@ -335,10 +341,35 @@
   }
 
   /** @override */
+  setWindowTitle(title) {
+    document.title = title;
+  }
+
+  /** @override */
   ringBell() {
     this.bell_.ring();
   }
 
+  /** @override */
+  print(str) {
+    this.xtermInternal_.print(str);
+  }
+
+  /** @override */
+  wipeContents() {
+    this.term.clear();
+  }
+
+  /** @override */
+  newLine() {
+    this.xtermInternal_.newLine();
+  }
+
+  /** @override */
+  cursorLeft(number) {
+    this.xtermInternal_.cursorLeft(number ?? 1);
+  }
+
   /**
    * Install stubs for stuff that we haven't implemented yet so that the code
    * still runs.
@@ -405,6 +436,10 @@
 
       return true;
     });
+
+    this.xtermInternal_.installTmuxControlModeHandler(
+        (data) => this.onTmuxControlModeLine(data));
+    this.xtermInternal_.installEscKHandler();
   }
 
   /**
@@ -412,18 +447,22 @@
    *
    * @param {string|!Uint8Array} data string for UTF-16 data, Uint8Array for
    *     UTF-8 data
+   * @param {function()=} callback Optional callback that fires when the data
+   *     was processed by the parser.
    */
-  write(data) {
-    this.term.write(data);
+  write(data, callback) {
+    this.term.write(data, callback);
   }
 
   /**
    * Like `this.write()` but also write a line break.
    *
    * @param {string|!Uint8Array} data
+   * @param {function()=} callback Optional callback that fires when the data
+   *     was processed by the parser.
    */
-  writeln(data) {
-    this.term.writeln(data);
+  writeln(data, callback) {
+    this.term.writeln(data, callback);
   }
 
   get screenSize() {
@@ -455,7 +494,6 @@
       this.inited_ = true;
       this.term.open(elem);
 
-      this.scheduleFit_();
       if (this.enableWebGL_) {
         this.term.loadAddon(new WebglAddon());
       }
@@ -485,6 +523,7 @@
         }
       });
 
+      await this.scheduleFit_();
       this.onTerminalReady();
     })();
   }
@@ -620,12 +659,9 @@
       return Math.floor((size - 2 * screenPaddingSize) / cellSize);
     };
 
-    // Unfortunately, it looks like we have to use private API from xterm.js.
-    // Maybe we should patch the FitAddon so that it works for us.
-    const dimensions = this.term._core._renderService.dimensions;
-    const cols = calc(this.container_.offsetWidth, dimensions.actualCellWidth);
-    const rows = calc(this.container_.offsetHeight,
-        dimensions.actualCellHeight);
+    const cellDimensions = this.xtermInternal_.getActualCellDimensions();
+    const cols = calc(this.container_.offsetWidth, cellDimensions.width);
+    const rows = calc(this.container_.offsetHeight, cellDimensions.height);
     if (cols >= 0 && rows >= 0) {
       this.term.resize(cols, rows);
     }
@@ -847,21 +883,6 @@
       set(modifiers | Modifier.Shift, keyCode, func);
     };
 
-    // Temporary shortcut to refresh the rendering in case of rendering errors.
-    // TODO(lxj): remove after this is fixed:
-    // https://github.com/xtermjs/xterm.js/issues/3878
-    set(Modifier.Ctrl | Modifier.Shift, keyCodes.L,
-        /** @suppress {missingProperties} */
-        () => {
-          this.scheduleRefreshFont_();
-          // Refresh the cursor layer.
-          if (this.enableWebGL_) {
-            this.term?._core?._renderService?._renderer?._renderLayers[1]
-                ?._clearAll();
-          }
-        },
-    );
-
     // Ctrl+/
     set(Modifier.Ctrl, 191, (ev) => {
       ev.preventDefault();
@@ -961,6 +982,27 @@
           (v) => fontManager.loadFont(/** @type {string} */(v)));
     });
   }
+
+  /**
+   * Write data to the terminal.
+   *
+   * @param {string|!Uint8Array} data string for UTF-16 data, Uint8Array for
+   *     UTF-8 data
+   * @param {function()=} callback Optional callback that fires when the data
+   *     was processed by the parser.
+   */
+  write(data, callback) {
+    if (typeof data === 'string') {
+      this.io.print(data);
+    } else {
+      this.io.writeUTF8(data);
+    }
+    // Hterm processes the data synchronously, so we can call the callback
+    // immediately.
+    if (callback) {
+      setTimeout(callback);
+    }
+  }
 }
 
 /**
diff --git a/terminal/js/terminal_emulator_tests.js b/terminal/js/terminal_emulator_tests.js
index 7949af0..3c2d980 100644
--- a/terminal/js/terminal_emulator_tests.js
+++ b/terminal/js/terminal_emulator_tests.js
@@ -20,16 +20,11 @@
           parser: {
             registerOscHandler: () => {},
           },
-          _core: {
-            _renderService: {
-              dimensions: {
-                actualCellWidth: 10.5,
-                actualCellHeight: 20.5,
-              },
-            },
-          },
         }),
         fontManager: new MockObject(),
+        xtermInternal: new MockObject({
+          getActualCellDimensions: () => ({width: 9, height: 22}),
+        }),
       };
       const testParams = {};
       for (const prop in this.mocks) {
diff --git a/terminal/js/terminal_xterm_internal.js b/terminal/js/terminal_xterm_internal.js
new file mode 100644
index 0000000..21347a1
--- /dev/null
+++ b/terminal/js/terminal_xterm_internal.js
@@ -0,0 +1,192 @@
+// Copyright 2022 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Implements XtermInternal, which interacts with the internal of
+ * xterm.js to provide extra functionalities. Unlike the public APIs, the
+ * internal of xterm.js is not stable, so we should try to minimize this file
+ * and have good test coverage.
+ */
+
+import {delayedScheduler} from './terminal_common.js';
+import {Terminal} from './xterm.js';
+
+const BUFFER_SIZE = 4096;
+
+/**
+ * @typedef {{
+ *     params: !Int32Array,
+ *     length: number,
+ * }}
+ */
+let IParams;
+
+/**
+ * A handler for tmux's DCS P sequence.
+ *
+ * Also see IDcsHandler at
+ * https://github.com/xtermjs/xterm.js/blob/2659de229173acf883f58401257d64aecc4138e1/src/common/parser/Types.d.ts#L79
+ */
+class TmuxDcsPHandler {
+  /**
+   * @param {function(?string)} onTmuxControlModeLine See
+   *     `hterm.Terminal.onTmuxControlModeLine`.
+   */
+  constructor(onTmuxControlModeLine) {
+    this.onTmuxControlModeLine_ = onTmuxControlModeLine;
+    /**
+     * The buffer to hold an incomplete tmux line. It is set to null if tmux
+     * control mode is not active.
+     *
+     * @type {?string}
+     */
+    this.buffer_ = null;
+  }
+
+  /** @param {!IParams} params */
+  hook(params) {
+    if (params.length === 1 && params.params[0] === 1000) {
+      this.buffer_ = '';
+      return;
+    }
+    console.warn('Unknown DCS P sequence. Params:',
+        params.params.slice(0, params.length));
+  }
+
+  /**
+   * @param {!Uint32Array} data
+   * @param {number} start
+   * @param {number} end
+   */
+  put(data, start, end) {
+    data = data.subarray(start, end);
+    if (this.buffer_ === null) {
+      return;
+    }
+
+    for (const code of data) {
+      const c = String.fromCodePoint(code);
+      if (c === '\n' && this.buffer_.slice(-1) === '\r') {
+        this.onTmuxControlModeLine_(this.buffer_.slice(0, -1));
+        this.buffer_ = '';
+        continue;
+      }
+      this.buffer_ += String.fromCodePoint(code);
+    }
+  }
+
+  /** @param {boolean} success */
+  unhook(success) {
+    if (this.buffer_ !== null) {
+      if (this.buffer_) {
+        console.warn('Unexpected tmux data before ST', {data: this.buffer_});
+      }
+      this.onTmuxControlModeLine_(null);
+    }
+    this.buffer_ = null;
+  }
+}
+
+
+export class XtermInternal {
+  /**
+   * @param {!Terminal} terminal
+   * @suppress {missingProperties}
+   */
+  constructor(terminal) {
+    this.terminal_ = terminal;
+
+    this.core_ = /** @type {{
+        _renderService: {
+          dimensions: {
+            actualCellHeight: number,
+            actualCellWidth: number,
+          },
+        },
+        _inputHandler: {
+          nextLine: function(),
+          print: function(!Uint32Array, number, number),
+          _moveCursor: function(number, number),
+          _parser: {
+            registerDcsHandler: function(!Object, !TmuxDcsPHandler),
+            _transitions: {
+              add: function(number, number, number, number),
+            },
+          }
+        },
+    }} */(this.terminal_._core);
+
+    this.encodeBuffer_ = new Uint32Array(BUFFER_SIZE);
+    this.scheduleFullRefresh_ = delayedScheduler(
+        () => this.terminal_.refresh(0, this.terminal_.rows), 10);
+  }
+
+  /**
+   * @return {{width: number, height: number}}
+   */
+  getActualCellDimensions() {
+    const dimensions = this.core_._renderService.dimensions;
+    return {
+      width: dimensions.actualCellWidth,
+      height: dimensions.actualCellHeight,
+    };
+  }
+
+  /**
+   * See hterm.Terminal.print.
+   *
+   * @param {string} str
+   */
+  print(str) {
+    let bufferLength = 0;
+    for (const c of str) {
+      this.encodeBuffer_[bufferLength++] = c.codePointAt(0);
+      if (bufferLength === BUFFER_SIZE) {
+        // The buffer is full. Let's send the data now.
+        this.core_._inputHandler.print(this.encodeBuffer_, 0, bufferLength);
+        bufferLength = 0;
+      }
+    }
+    this.core_._inputHandler.print(this.encodeBuffer_, 0, bufferLength);
+    this.scheduleFullRefresh_();
+  }
+
+  newLine() {
+    this.core_._inputHandler.nextLine();
+  }
+
+  /**
+   * @param {number} number
+   */
+  cursorLeft(number) {
+    this.core_._inputHandler._moveCursor(-number, 0);
+    this.scheduleFullRefresh_();
+  }
+
+  /**
+   * Install a ESC k (set window name) handler for tmux. The data in the
+   * sequence is ignored.
+   */
+  installEscKHandler() {
+    this.core_._inputHandler._parser._transitions.add(
+        // k
+        0x6b,
+        // ParserState.ESCAPE,
+        1,
+        // ParserAction.IGNORE
+        0,
+        // ParserState.DCS_IGNORE
+        11,
+    );
+  }
+
+  /**
+   * @param {function(?string)} onTmuxControlModeLine See
+   *     `hterm.Terminal.onTmuxControlModeLine`.
+   */
+  installTmuxControlModeHandler(onTmuxControlModeLine) {
+    this.core_._inputHandler._parser.registerDcsHandler({final: 'p'},
+        new TmuxDcsPHandler(onTmuxControlModeLine));
+  }
+}
diff --git a/terminal/js/terminal_xterm_internal_tests.js b/terminal/js/terminal_xterm_internal_tests.js
new file mode 100644
index 0000000..00d2c2b
--- /dev/null
+++ b/terminal/js/terminal_xterm_internal_tests.js
@@ -0,0 +1,112 @@
+// Copyright 2022 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Tests for terminal_xterm_internal.js
+ */
+
+import {Terminal} from './xterm.js';
+import {XtermInternal} from './terminal_xterm_internal.js';
+
+const COLS = 80;
+const ROWS = 24;
+
+describe('terminal_xterm_internal.js', function() {
+  beforeEach(function() {
+    this.elem = document.createElement('div');
+    this.elem.style.height = '500px';
+    this.elem.style.width = '500px';
+    document.body.appendChild(this.elem);
+
+    this.terminal = new Terminal({cols: COLS, rows: ROWS,
+      allowProposedApi: true});
+    this.terminal.open(this.elem);
+    this.terminal.options.fontFamily = '"Noto Sans Mono"';
+    this.terminal.options.fontSize = 16;
+    this.xtermInternal = new XtermInternal(this.terminal);
+
+    this.write = async (content) => {
+      return new Promise((resolve) => this.terminal.write(content, resolve));
+    };
+  });
+
+  afterEach(function() {
+    this.terminal.dispose();
+    document.body.removeChild(this.elem);
+  });
+
+  it('getActualCellDimensions()', async function() {
+    const {width, height} = this.xtermInternal.getActualCellDimensions();
+    assert.isAbove(width, 0);
+    assert.isAbove(height, 0);
+  });
+
+  it('print()', async function() {
+    this.xtermInternal.print('hello world');
+    assert.equal(this.terminal.buffer.active.getLine(0).translateToString(true),
+        'hello world');
+  });
+
+  it('newLine()', async function() {
+    await this.write('012');
+    const buffer = this.terminal.buffer.active;
+    assert.equal(buffer.cursorX, 3);
+    assert.equal(buffer.cursorY, 0);
+    this.xtermInternal.newLine();
+    assert.equal(buffer.cursorX, 0);
+    assert.equal(buffer.cursorY, 1);
+  });
+
+  it('cursorLeft()', async function() {
+    await this.write('012');
+    const buffer = this.terminal.buffer.active;
+    assert.equal(buffer.cursorX, 3);
+    assert.equal(buffer.cursorY, 0);
+    this.xtermInternal.cursorLeft(1);
+    assert.equal(this.terminal.buffer.active.cursorX, 2);
+    assert.equal(buffer.cursorY, 0);
+  });
+
+  it('installEscKHandler()', async function() {
+    await this.write('\x1bkhello world\x1b\\');
+    assert.equal(this.terminal.buffer.active.getLine(0).translateToString(true),
+        'hello world',
+        'before installing the handler, the string will be printed');
+
+    this.xtermInternal.installEscKHandler();
+    await this.write('abc\x1bk1234\x1b\\def');
+    assert.equal(this.terminal.buffer.active.getLine(0).translateToString(true),
+        'hello worldabcdef',
+        'after installing the handler, the string should be ignored');
+  });
+
+  it('installTmuxControlModeHandler()', async function() {
+    const tmuxLines = [];
+
+    this.xtermInternal.installTmuxControlModeHandler((line) => {
+      tmuxLines.push(line);
+    });
+
+    for (const input of [
+      'hello world\x1bP',
+      '1000phello ',
+      'tmux\r',
+      '\nhello',
+      ' again\r\nbye',
+    ]) {
+      await this.write(input);
+    }
+
+    assert.equal(this.terminal.buffer.active.getLine(0).translateToString(true),
+        'hello world');
+    assert.deepEqual(tmuxLines, ['hello tmux', 'hello again']);
+    tmuxLines.length = 0;
+
+    await this.write(' tmux\r\n\x1b\\abcd');
+    assert.deepEqual(tmuxLines, ['bye tmux', null]);
+
+    assert.equal(this.terminal.buffer.active.getLine(0).translateToString(true),
+        'hello worldabcd');
+  });
+});