mirror of
https://github.com/aphrody-labs/opentui.git
synced 2026-10-13 21:08:53 +00:00
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:
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("")
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user