Skip to content
Draft
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
20 changes: 20 additions & 0 deletions doc/api/tty.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,26 @@ changes:
`writeStream.cursorTo()` moves this `WriteStream`'s cursor to the specified
position.

### `writeStream.getBackgroundColor()`

<!-- YAML
added: REPLACEME
-->

* Returns: {Promise} Resolves with `{ r, g, b }` (0-255 each) representing the
terminal's background color, or `undefined` if the terminal does not
support or respond to the query in time.

Queries the terminal for its background color using the OSC 11 escape
sequence and resolves with the result.

This is a best-effort API. Support depends on the terminal emulator and
environment — for example, some terminal multiplexers (such as tmux) do not
respond to this query at all, and some terminals have been observed to
respond with incorrect values. Results should not be treated as
authoritative, and callers should have a fallback for when the Promise
resolves to `undefined`.

### `writeStream.getColorDepth([env])`

<!-- YAML
Expand Down
116 changes: 116 additions & 0 deletions lib/tty.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,122 @@ WriteStream.prototype.getColorDepth = getColorDepth;

WriteStream.prototype.hasColors = hasColors;


// Queries the terminal for its background color via the OSC 11 escape
// sequence (`\x1b]11;?\x07`). Best-effort: resolves `undefined` if the
// terminal does not respond within the timeout, does not support the
// query, or is running under an environment known not to answer it
// (e.g. GNU Screen, some tmux configurations).
//
// The response to an OSC query does not arrive on the write side of the
// stream -- terminals reply as if the user had typed the response, so it
// must be read back separately. We first try opening a short-lived
// raw-mode ReadStream on the same underlying fd (this covers Unix ptys,
// where a single fd is bidirectional), falling back to the process's
// real stdin fd (0) if that isn't supported (e.g. Windows, where output
// and input use separate handles). We always tear the reader down
// (restoring cooked mode and removing listeners) on every exit path, so
// a terminal that responds incorrectly or not at all can never leak
// bytes into the user's real input stream.
WriteStream.prototype.getBackgroundColor = function(options = {}) {
const { timeout = 200 } = options;

return new Promise((resolve) => {
const env = process.env;
if (env.TERM === 'screen' && !env.TERM_PROGRAM) {
resolve(undefined);
return;
}

if (!this.isTTY) {
resolve(undefined);
return;
}

let settled = false;
let reader;
let timer;
let buffer = '';

const cleanup = () => {
if (timer) clearTimeout(timer);
if (reader) {
reader.removeListener('data', onData);
try {
reader.setRawMode(false);
} catch {
// Not fatal -- fd may already be closed or unsupported.
}
reader.destroy();
}
};

const finish = (value) => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
};

const onData = (chunk) => {
buffer += chunk.toString('latin1');

const belIndex = buffer.indexOf('\x07');
const stIndex = buffer.indexOf('\x1b\\');
const termIndex = belIndex === -1 ? stIndex :
(stIndex === -1 ? belIndex : Math.min(belIndex, stIndex));

if (termIndex === -1) return;

const reply = buffer.slice(0, termIndex);
const match = /rgb:([0-9a-f]{2,4})\/([0-9a-f]{2,4})\/([0-9a-f]{2,4})/i
.exec(reply);

if (!match) {
finish(undefined);
return;
}

const toByte = (hex) => parseInt(hex.slice(0, 2), 16);

finish({
r: toByte(match[1]),
g: toByte(match[2]),
b: toByte(match[3]),
});
};

// Some platforms (e.g. Windows) use separate handles for a terminal's
// input and output, so the same fd used for writing cannot always be
// put into raw read mode. Try the write-side fd first (this covers
// Unix ptys, where a single fd is bidirectional), and fall back to
// the process's real stdin fd if that fails.
const tryOpenReader = (fd) => {
const candidate = new ReadStream(fd);
candidate.setRawMode(true);
return candidate;
};

try {
reader = tryOpenReader(this._handle.fd);
} catch {
try {
reader = tryOpenReader(0);
} catch {
finish(undefined);
return;
}
}

reader.on('data', onData);

timer = setTimeout(() => finish(undefined), timeout);
timer.unref();

this.write('\x1b]11;?\x07');
});
};

WriteStream.prototype._refreshSize = function() {
const oldCols = this.columns;
const oldRows = this.rows;
Expand Down
30 changes: 30 additions & 0 deletions test/parallel/test-tty-osc11-bg-color.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use strict';
const common = require('../common');

if (!common.isMainThread)
common.skip('process.stdout is not a tty in Workers');

const assert = require('assert');
const tty = require('tty');

// getBackgroundColor() must always return a Promise, and that Promise
// must settle (never hang forever) even when the terminal doesn't
// respond. Use a short timeout override so this doesn't slow down CI
// on machines without a responsive terminal attached.
const stream = new tty.WriteStream(1);

assert.strictEqual(typeof stream.getBackgroundColor, 'function');

const result = stream.getBackgroundColor({ timeout: 50 });
assert.ok(result instanceof Promise);

result.then(common.mustCall((color) => {
if (color !== undefined) {
assert.strictEqual(typeof color.r, 'number');
assert.strictEqual(typeof color.g, 'number');
assert.strictEqual(typeof color.b, 'number');
for (const channel of [color.r, color.g, color.b]) {
assert.ok(channel >= 0 && channel <= 255);
}
}
}));