fix(terminal): read the colour env from the registry on every local spawn path

buildClaudeEnv(), the direct-PTY fallback taken when mux creation fails, now
reads getCli('claude').env and applies its unset and exports lists. It used to
delete COLORTERM and CLAUDECODE from a hand-maintained list of its own, which
left it contradicting the registry entry that the tmux pane and the attach
client both read. An engine value needing a mux name has nothing to resolve
against on this path, so it is skipped rather than guessed.

Claude no longer unsets NO_COLOR. The invisible-background bug does not need
it, and unsetting it overrides a preference the user set deliberately, so a
user who exports NO_COLOR globally keeps monochrome panes. The other seven
truecolor CLIs still unset it; that inconsistency is intentional and the
comment on the entry says so.

The invariants doc gains a Terminal colour env paragraph under Session launch
modes, where a reader looking up Claude will find it — the previous sentence
sat under a heading that lists only the non-Claude CLIs. It now says the lists
are the stock catalog and a clis.json override replaces them wholesale, and
that the declarations reach the tmux pane, its attach client and the direct
PTY but not a remote pane, whose command carries no env exports at all. Docker
hands COLORTERM=truecolor to every mode, including the two the registry says
must unset it.

The changeset named six peer CLIs and there are seven: deepseek also exports
truecolor. A test beside the existing OpenCode assertion pins the new
behaviour, so a future registry edit cannot make the backgrounds vanish again
in silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-13 14:42:30 +02:00
co-authored by Claude Opus 5
parent 7767b16d4f
commit dae2ac580f
5 changed files with 43 additions and 9 deletions
+10 -4
View File
@@ -11,11 +11,17 @@ therefore quantized every RGB color its theme asked for down to the basic palett
`rgb(55, 55, 55)` and every other dark background becomes `ESC[40m` — the terminal's own `rgb(55, 55, 55)` and every other dark background becomes `ESC[40m` — the terminal's own
black. A custom Claude theme could change the color and nothing on screen moved. black. A custom Claude theme could change the color and nothing on screen moved.
Claude now exports `COLORTERM=truecolor` and unsets `NO_COLOR`, which is what codex, gemini, Claude now exports `COLORTERM=truecolor`, which is what codex, gemini, antigravity, pi, grok,
antigravity, pi, grok and omp already do. `CLAUDECODE` stays unset, because Claude reads it deepseek and omp already do. Those seven also unset `NO_COLOR`; Claude does not, so a user who
as a signal that it is running nested inside itself. exports `NO_COLOR` globally keeps the monochrome panes they asked for. `CLAUDECODE` stays
unset, because Claude reads it as a signal that it is running nested inside itself.
`buildClaudeEnv()`, the direct-PTY fallback used when tmux is unavailable, now reads the same
registry entry as the tmux pane and its attach client instead of deleting `COLORTERM` from a
hand-maintained list of its own. A remote pane still exports nothing — `buildRemoteLaunchCommand()`
never carried these declarations — so an SSH-remote Claude session keeps the old rendering.
PR #3 introduced the `unset COLORTERM` in February, citing xterm.js#484 for the claim that PR #3 introduced the `unset COLORTERM` in February, citing xterm.js#484 for the claim that
xterm.js mishandles truecolor. xterm.js closed that issue in April 2019, Codeman now depends xterm.js mishandles truecolor. xterm.js closed that issue in April 2019, Codeman now depends
on `@xterm/xterm` 6, and `TmuxManager` sets `terminal-overrides ",*:Tc"` on its own tmux on `@xterm/xterm` 6, and `TmuxManager` sets `terminal-overrides ",*:Tc"` on its own tmux
server, so 24-bit color already reaches the browser for the six CLIs that ask for it. server, so 24-bit color already reaches the browser for the CLIs that ask for it.
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -174,17 +174,19 @@ const CLAUDE: CliEntry = {
legacyConfigAliases: { resumeId: 'resumeSessionId' }, legacyConfigAliases: { resumeId: 'resumeSessionId' },
}, },
env: { env: {
// Claude joins the truecolor list with COLORTERM=truecolor and NO_COLOR unset. // Claude asks for truecolor, like every CLI here except `shell` and `opencode`.
// tmux hands the pane TERM=screen, which supports-color reads as 16 colors, and // tmux hands the pane TERM=screen, which supports-color reads as 16 colors, and
// Claude then quantizes every RGB color its theme asks for down to that palette. // Claude then quantizes every RGB color its theme asks for down to that palette.
// Each dark background lands on ESC[40m, the terminal's own black, so the block // Each dark background lands on ESC[40m, the terminal's own black, so the block
// Claude draws behind the user's own messages renders invisible. PR #3 unset // Claude draws behind the user's own messages renders invisible. PR #3 unset
// COLORTERM here against xterm.js#484, which xterm.js had already closed in 2019, // COLORTERM here against xterm.js#484, which xterm.js had already closed in 2019,
// and Codeman now ships @xterm/xterm 6 and sets `terminal-overrides *:Tc` itself. // and Codeman now ships @xterm/xterm 6 and sets `terminal-overrides *:Tc` itself.
// The other truecolor CLIs also unset NO_COLOR. Claude does not, so a user who
// exports NO_COLOR globally keeps the monochrome panes they asked for.
// CLAUDECODE stays unset, because Claude reads it as a signal that it is running // CLAUDECODE stays unset, because Claude reads it as a signal that it is running
// nested inside itself. // nested inside itself.
exports: [{ name: 'COLORTERM', value: 'truecolor' }], exports: [{ name: 'COLORTERM', value: 'truecolor' }],
unset: ['CLAUDECODE', 'NO_COLOR'], unset: ['CLAUDECODE'],
tmuxSetenvKeys: [], tmuxSetenvKeys: [],
dockerExecEnvNames: [], dockerExecEnvNames: [],
allowedPrefixes: ['CLAUDE_CODE_'], allowedPrefixes: ['CLAUDE_CODE_'],
+19 -2
View File
@@ -13,6 +13,7 @@ import { isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js'; import { getAugmentedPath } from './utils/index.js';
import { compareVersions } from './utils/dependency-checker.js'; import { compareVersions } from './utils/dependency-checker.js';
import { dataPath } from './config/instance.js'; import { dataPath } from './config/instance.js';
import { getCli } from './config/cli-registry/registry.js';
/** /**
* Build Claude CLI permission flags based on the configured mode. * Build Claude CLI permission flags based on the configured mode.
@@ -181,10 +182,26 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
// Path only (not the secret value) — hook curls cat it at execution time (COD-54) // Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'), CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
}; };
// The colour and identity vars come from the registry entry, the same source
// buildEnvExports() and buildMuxAttachEnv() read, so this fallback cannot drift from
// the tmux pane the way a hand-maintained list here did.
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined // COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below). // key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
delete env.COLORTERM; const cliEnv = getCli('claude')?.env;
delete env.CLAUDECODE; for (const name of cliEnv?.unset ?? []) delete env[name];
for (const item of cliEnv?.exports ?? []) {
// A direct PTY has no mux, so `muxName` has no value to resolve against. Claude
// declares literals only; an unresolvable engine value is skipped, never guessed.
const value =
typeof item.value === 'string'
? item.value
: item.value.engine === 'sessionId'
? sessionId
: item.value.engine === 'codemanPrefixedSessionId'
? `codeman_${sessionId}`
: undefined;
if (value !== undefined) env[item.name] = value;
}
return env; return env;
} }
+7
View File
@@ -295,6 +295,13 @@ describe('TmuxManager (unit)', () => {
expect(callBuildEnvExports('opencode')).toContain('unset COLORTERM'); expect(callBuildEnvExports('opencode')).toContain('unset COLORTERM');
}); });
// Claude renders its themed backgrounds as RGB. Without this the pane inherits
// tmux's TERM=screen, supports-color reads 16 colors, and every dark background
// quantizes to ESC[40m — the terminal's own black — so the block goes invisible.
it('exports truecolor for Claude sessions', () => {
expect(callBuildEnvExports('claude')).toContain('export COLORTERM=truecolor');
});
it('exports the server-stamped CODEMAN_API_URL verbatim', () => { it('exports the server-stamped CODEMAN_API_URL verbatim', () => {
const original = process.env.CODEMAN_API_URL; const original = process.env.CODEMAN_API_URL;
process.env.CODEMAN_API_URL = 'https://127.0.0.1:3199'; process.env.CODEMAN_API_URL = 'https://127.0.0.1:3199';