native: keep TERM and COLORTERM color depth in auto remote sessions (#1624)

An app started inside an SSH or mosh login runs in auto-detected remote
mode. In that mode, native detection
returned before it read `TERM` and `COLORTERM`, so neither `ansi256` nor
`rgb` was ever set, and the renderer sent
truecolor SGR to every remote terminal. A 256-color endpoint such as GNU
screen 4.x then showed the UI without
color, while the same `TERM` worked locally.

This supersedes #1492 by @yurial and keeps its commit as authored. The
fix is that commit's design: read the color
depth keys (a `TERM` that contains `256color`, a `COLORTERM` of
`truecolor` or `24bit`) before the auto remote
return, and keep the return for the identity keys such as
`TERM_PROGRAM`, the multiplexer variables, and the
`OPENTUI_*` overrides, which can describe the wrong endpoint on the
process host. The later commits port its
renderer test to the current test fixtures (it used the pool API that
#1479 removed, so `test:native` did not
compile), read the two keys in one place instead of through a helper
called on both sides of the return, and fold
the tests into one table. Local and explicit remote mode, which
`@opentui/ssh` uses, give the same result as
before. Color depth values in `environment` now take effect in auto
remote mode too; the other values still need
`remote: true`, and the terminal capabilities docs now say so.

There is a trade-off. A truecolor terminal that reaches the server with
`TERM=xterm-256color` and no `COLORTERM`,
for example iTerm2, WezTerm, or Ghostty over a stock macOS ssh client,
used to get exact colors by accident and
now gets 256-color approximations, which is what it gets locally. A
Kitty XTVERSION reply still turns on `rgb`.
The docs explain how to keep truecolor: export `COLORTERM=truecolor` on
the server, forward it with
`SendEnv`/`AcceptEnv`, or pass it in `environment`.

A table in `terminal_test.zig` runs the same `SSH_CONNECTION`, `TERM`,
and `COLORTERM` rows through local, auto,
and explicit remote mode, with both an `env_map` passed at init and
forwarded `setHostEnvVar` values. The renderer
fallback test now gets `ansi256` from an auto remote forwarded `TERM`
and checks for `38;5` output, and the
remote TypeScript tests check `ansi256` and `rgb` from forwarded and
explicit values. On main the forwarded
auto remote rows and these tests fail. Moving the checks back after the
return, dropping the `TERM` check or `24bit`,
accepting any `COLORTERM`, reading only forwarded maps, matching
`256color` only as a suffix, or deleting the
return each makes at least one test fail.

Fixes #1491

---------

Co-authored-by: Yuri Dyachenko <pub.yurial@gmail.com>
This commit is contained in:
Simon KleeandYuri Dyachenko authored and GitHub committed 2026-10-10 15:03:37 +02:00
1 parent e449ad6b32
commit 786d423e9c
5 files changed
+68 -41

No files matched your search

@@ -76,9 +76,9 @@ async function getCapabilitiesFromChild(
}
describe("remote detection", () => {
test("auto remote mode detects SSH and skips default terminal env forwarding", async () => {
test("auto remote mode detects SSH and ignores terminal identity but not color depth", async () => {
const caps = await getCapabilitiesFromChild(
{},
{ environment: { COLORTERM: "truecolor", TERM_PROGRAM: "WezTerm" } },
{
PATH: process.env.PATH ?? "",
HOME: process.env.HOME ?? "",
@@ -91,7 +91,7 @@ describe("remote detection", () => {
)
expect(caps.remote).toBe(true)
expect(caps.ansi256).toBe(false)
expect([caps.ansi256, caps.rgb]).toEqual([true, true])
expect(caps.notifications).toBe(false)
expect(caps.terminal.name).toBe("")
})
@@ -217,7 +217,7 @@ describe("remote detection", () => {
)
expect(caps.remote).toBe(true)
expect(caps.ansi256).toBe(false)
expect([caps.ansi256, caps.rgb]).toEqual([true, false])
expect(caps.notifications).toBe(false)
expect(caps.terminal.name).toBe("")
})
+15 -12
View File
@@ -712,6 +712,21 @@ pub fn checkEnvironmentOverrides(self: *Terminal) void {
const env_is_forwarded = if (self.host_env_map) |*host_env_map| env_map == host_env_map else false;
self.applyKnownUnicodeWidthIdentity();
// Unlike the identity keys below, color depth describes the endpoint in every mode:
// sshd takes TERM from the client's PTY request, and COLORTERM comes from the client or the session itself.
if (env_map.get("TERM")) |term| {
if (std.ascii.findIgnoreCase(term, "256color") != null) {
self.caps.ansi256 = true;
}
}
if (env_map.get("COLORTERM")) |colorterm| {
if (std.mem.eql(u8, colorterm, "truecolor") or
std.mem.eql(u8, colorterm, "24bit"))
{
self.caps.rgb = true;
self.caps.ansi256 = true;
}
}
if (self.opts.remote_mode == .auto and self.remote and env_is_forwarded) {
return;
}
@@ -751,9 +766,6 @@ pub fn checkEnvironmentOverrides(self: *Terminal) void {
}
if (env_map.get("TERM")) |term| {
if (std.ascii.findIgnoreCase(term, "256color") != null) {
self.caps.ansi256 = true;
}
self.applyNotificationHeuristic(term);
self.is_foot = self.is_foot or std.ascii.findIgnoreCase(term, "foot") != null;
}
@@ -836,15 +848,6 @@ pub fn checkEnvironmentOverrides(self: *Terminal) void {
}
}
if (env_map.get("COLORTERM")) |colorterm| {
if (std.mem.eql(u8, colorterm, "truecolor") or
std.mem.eql(u8, colorterm, "24bit"))
{
self.caps.rgb = true;
self.caps.ansi256 = true;
}
}
if (env_map.get("WT_SESSION") != null) {
self.caps.rgb = true;
self.caps.ansi256 = true;
+4 -3
View File
@@ -2430,7 +2430,7 @@ test "renderer - indexed snapshots fall back to rgb and explicit bg default rese
try std.testing.expect(std.mem.find(u8, output, "\x1b[49m") != null);
}
test "renderer - rgb colors fall back to ANSI256 mapping when rgb is unavailable" {
test "renderer - rgb colors fall back to ANSI256 mapping from auto remote forwarded TERM" {
var pools = TestPools.init(std.testing.allocator);
defer pools.deinit();
@@ -2444,8 +2444,9 @@ test "renderer - rgb colors fall back to ANSI256 mapping when rgb is unavailable
defer test_cli_renderer.deinit();
const cli_renderer = test_cli_renderer.renderer;
cli_renderer.terminal.caps.rgb = false;
cli_renderer.terminal.caps.ansi256 = true;
cli_renderer.terminal.opts.remote_mode = .auto;
try cli_renderer.terminal.setHostEnvVar(std.testing.allocator, "SSH_CONNECTION", "192.0.2.1 54231 192.0.2.2 22");
try cli_renderer.terminal.setHostEnvVar(std.testing.allocator, "TERM", "screen-256color");
const next_buffer = cli_renderer.getNextBuffer();
try next_buffer.drawText("A", 0, 0, ansi.rgbaFromFloats(0.95, 0.1, 0.1, 1.0), ansi.rgbaFromFloats(0.0, 0.0, 0.0, 1.0), 0);
+33 -18
View File
@@ -583,7 +583,7 @@ test "remote detection - auto mode detects mosh environment" {
try testing.expect(term.caps.remote);
}
test "remote detection - auto mode ignores local capabilities after forwarded SSH marker" {
test "remote detection - auto mode ignores forwarded terminal identity after SSH marker" {
var term = Terminal.init(.{ .remote_mode = .auto });
defer term.deinit();
@@ -594,11 +594,42 @@ test "remote detection - auto mode ignores local capabilities after forwarded SS
try testing.expect(term.caps.remote);
try testing.expectEqual(utf8.WidthMethod.unicode, term.caps.unicode);
try testing.expect(!term.caps.ansi256);
try testing.expect(!term.caps.notifications);
try testing.expectEqualStrings("", term.getTerminalName());
}
test "remote detection - TERM and COLORTERM set color depth in every remote mode" {
const cases = [_]struct { term: []const u8, colorterm: ?[]const u8 = null, ansi256: bool, rgb: bool }{
.{ .term = "screen-256color", .ansi256 = true, .rgb = false },
.{ .term = "screen", .colorterm = "truecolor", .ansi256 = true, .rgb = true },
.{ .term = "screen", .colorterm = "24bit", .ansi256 = true, .rgb = true },
.{ .term = "xterm-256color-italic", .colorterm = "yes", .ansi256 = true, .rgb = false },
.{ .term = "dumb", .colorterm = "256", .ansi256 = false, .rgb = false },
.{ .term = "", .colorterm = "", .ansi256 = false, .rgb = false },
};
// The authoritative env_map takes the full path in every mode; setHostEnvVar forwards one key at a time.
for ([_]Terminal.RemoteMode{ .local, .auto, .remote }) |mode| for ([_]bool{ false, true }) |forwarded| {
for (cases) |case| {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("SSH_CONNECTION", "192.0.2.1 54231 192.0.2.2 22");
try env.put("TERM", case.term);
if (case.colorterm) |colorterm| try env.put("COLORTERM", colorterm);
var term = Terminal.init(.{ .remote_mode = mode, .env_map = if (forwarded) null else &env });
defer term.deinit();
if (forwarded) {
var entries = env.iterator();
while (entries.next()) |entry| try term.setHostEnvVar(testing.allocator, entry.key_ptr.*, entry.value_ptr.*);
}
try testing.expectEqual(mode != .local, term.caps.remote);
try testing.expectEqual(case.ansi256, term.caps.ansi256);
try testing.expectEqual(case.rgb, term.caps.rgb);
}
};
}
test "remote detection - explicit local mode ignores SSH environment" {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
@@ -934,22 +965,6 @@ test "environment overrides - FORCE_HYPERLINK forces hyperlink support" {
try testing.expect(!term.caps.hyperlinks);
}
test "setHostEnvVar detects ansi256 separately from rgb" {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("TERM", "screen-256color");
var term = Terminal.init(.{ .env_map = &env });
defer term.deinit();
try testing.expect(term.caps.ansi256);
try testing.expect(!term.caps.rgb);
try term.setHostEnvVar(testing.allocator, "COLORTERM", "truecolor");
try testing.expect(term.caps.rgb);
try testing.expect(term.caps.ansi256);
}
test "environment overrides - WT_SESSION enables rgb and ansi256" {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
@@ -143,8 +143,16 @@ A custom `stdout` that uses the native span feed defaults to `remote: true`. Ope
host terminal is the stream's real peer.
Auto-detection recognizes `SSH_CONNECTION`, `SSH_CLIENT`, `SSH_TTY`, and `MOSH_CONNECTION`. In an auto-detected remote
session, OpenTUI does not apply the process host's forwarded terminal heuristics by default. This prevents a host
`TERM_PROGRAM` value from describing the wrong terminal endpoint.
session, OpenTUI applies only the color depth keys from the forwarded environment: a `TERM` that contains `256color`
enables `ansi256`, and a `COLORTERM` of `truecolor` or `24bit` enables `rgb` and `ansi256`. These keys describe the
remote endpoint: `sshd` takes `TERM` from the client's PTY request, and `COLORTERM` comes from the client when ssh is
configured to send it, or from the server session itself, such as a shell rc file. OpenTUI ignores the other keys, such
as `TERM_PROGRAM`, the multiplexer variables, and the `OPENTUI_*` overrides. On the process host, they can describe the
wrong terminal endpoint.
Without `COLORTERM`, a `256color` `TERM` gives 256-color output, even in a terminal that supports truecolor. To keep
truecolor in an SSH session, export `COLORTERM=truecolor` on the server, forward it with `SendEnv COLORTERM` on the
client and `AcceptEnv COLORTERM` on the server, or pass it in `environment`.
`forwardEnvKeys` selects process environment names that OpenTUI sends to native detection. Explicit remote mode
defaults to an empty list. Other modes default to the documented terminal, SSH, tmux, Zellij, width, graphics,
@@ -166,8 +174,8 @@ const renderer = await createCliRenderer({
`forwardEnvKeys` reads the process environment, so it describes the remote endpoint only when the process runs
inside that endpoint's session. A server with many clients passes each client's values in `environment` instead,
for example `environment: { TERM: pty.term, COLORTERM: "truecolor" }`. These values replace forwarded values with
the same name. When auto-detection finds an SSH login, it does not apply terminal values, explicit ones included; set
`remote: true` for them to take effect, as `@opentui/ssh` does.
the same name. When auto-detection finds an SSH login, OpenTUI applies only their color depth keys, as it does for
forwarded values. Set `remote: true` for the other values to take effect, as `@opentui/ssh` does.
Custom streams do not receive `SIGWINCH`. Call `renderer.resize(width, height)` when the remote transport reports a
window change. Read [SSH](/docs/reference/ssh) for the full session setup.