From dae2ac580f2a169f6648e4d22c575f09f64fd11e Mon Sep 17 00:00:00 2001 From: Michael Grundberg Date: Sun, 13 Sep 2026 14:42:30 +0200 Subject: [PATCH] fix(terminal): read the colour env from the registry on every local spawn path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/fix-claude-truecolor-in-panes.md | 14 ++++++++++---- docs/architecture-invariants.md | 4 +++- src/config/cli-registry/stock.ts | 6 ++++-- src/session-cli-builder.ts | 21 +++++++++++++++++++-- test/tmux-manager.test.ts | 7 +++++++ 5 files changed, 43 insertions(+), 9 deletions(-) diff --git a/.changeset/fix-claude-truecolor-in-panes.md b/.changeset/fix-claude-truecolor-in-panes.md index 71ea5ab1..593de379 100644 --- a/.changeset/fix-claude-truecolor-in-panes.md +++ b/.changeset/fix-claude-truecolor-in-panes.md @@ -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 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, -antigravity, pi, grok and omp already do. `CLAUDECODE` stays unset, because Claude reads it -as a signal that it is running nested inside itself. +Claude now exports `COLORTERM=truecolor`, which is what codex, gemini, antigravity, pi, grok, +deepseek and omp already do. Those seven 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 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 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 -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. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 8b22ae88..031b38f0 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -18,9 +18,11 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ## Session launch modes +**Terminal colour env** — the stock registry decides each CLI's colour vars. Claude, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor`; `shell` and `opencode` unset it. All of those except Claude also unset `NO_COLOR`, so a user who exports `NO_COLOR` globally keeps monochrome Claude panes. The variable matters because tmux gives each pane `TERM=screen`, which supports-color reads as 16 colors: a CLI inheriting no `COLORTERM` quantizes every RGB color it draws down to the basic palette, and each dark background lands on `ESC[40m`, the terminal's own black. ⚠️ These declarations reach the local tmux pane via `buildEnvExports()`, its attach client via `cliExportsTruecolor()`, and the direct-PTY fallback via `buildClaudeEnv()` — they do NOT reach a remote pane, which `buildRemoteLaunchCommand()` builds with no env exports at all. A Docker pane takes `COLORTERM=truecolor` from the hardcoded `envCreate`/`execEnv` in `tmux-manager.ts`, which apply to every mode including the two the registry says must unset it. A `~/.codeman/clis.json` override replaces these arrays wholesale (`deepMerge`), so a custom entry can drop either list. + ### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Every CLI except `shell` and `opencode` exports `COLORTERM=truecolor` and unsets `NO_COLOR`. tmux gives each pane `TERM=screen`, which supports-color reads as 16 colors, so a CLI that inherits no `COLORTERM` quantizes every RGB color it draws down to the basic palette and loses each dark background to `ESC[40m`, the terminal's own black. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. **DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it. diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index a03d4768..52471f7c 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -174,17 +174,19 @@ const CLAUDE: CliEntry = { legacyConfigAliases: { resumeId: 'resumeSessionId' }, }, 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 // 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 // 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, // 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 // nested inside itself. exports: [{ name: 'COLORTERM', value: 'truecolor' }], - unset: ['CLAUDECODE', 'NO_COLOR'], + unset: ['CLAUDECODE'], tmuxSetenvKeys: [], dockerExecEnvNames: [], allowedPrefixes: ['CLAUDE_CODE_'], diff --git a/src/session-cli-builder.ts b/src/session-cli-builder.ts index 482c8105..904e1dd5 100644 --- a/src/session-cli-builder.ts +++ b/src/session-cli-builder.ts @@ -13,6 +13,7 @@ import { isEffortLevel } from './types.js'; import { getAugmentedPath } from './utils/index.js'; import { compareVersions } from './utils/dependency-checker.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. @@ -181,10 +182,26 @@ export function buildClaudeEnv(sessionId: string): Record { 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', () => { const original = process.env.CODEMAN_API_URL; process.env.CODEMAN_API_URL = 'https://127.0.0.1:3199';