Skip to content

Commit 7a51159

Browse files
committed
feat: forward xterm theme background as COLORFGBG to spawned PTYs
Inject COLORFGBG (derived from the active xterm.js theme background via Rec. 601 luma) into the env of every PTY the server spawns. This gives Claude Code, Codex, and other TUIs a reliable light/dark signal so they can match the page theme. Windows ConPTY intercepts OSC 11 background-color queries and never forwards xterm.js's response back to the child, leaving TUIs stuck on their dark default. On a light coder-studio theme this surfaces as a stark black bar behind user messages. COLORFGBG is the only signal that survives the ConPTY layer. - TerminalSpec gains an optional themeBackground hex string forwarded through session.create / terminal.create command schemas - TerminalManager derives COLORFGBG once at spawn time and lets spec.env override when callers need to pin a specific value - useTerminalThemeBackground hook reads the active theme atom and is threaded through every session/terminal create call site
1 parent d39e6cb commit 7a51159

18 files changed

Lines changed: 271 additions & 23 deletions

File tree

packages/server/src/commands/session.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ registerCommand(
4242
workspaceId: z.string(),
4343
providerId: z.string(),
4444
draft: z.string().optional(),
45+
themeBackground: z
46+
.string()
47+
.regex(/^#[0-9a-fA-F]{3,8}$/)
48+
.optional(),
4549
}),
4650
async (args, ctx) => {
4751
// Get workspace
@@ -75,6 +79,7 @@ registerCommand(
7579
providerId: args.providerId,
7680
provider,
7781
draft: args.draft,
82+
themeBackground: args.themeBackground,
7883
});
7984
}
8085
);

packages/server/src/commands/terminal.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ registerCommand(
154154
cols: z.number().int().positive().optional(),
155155
rows: z.number().int().positive().optional(),
156156
cwdPath: z.string().optional(),
157+
themeBackground: z
158+
.string()
159+
.regex(/^#[0-9a-fA-F]{3,8}$/)
160+
.optional(),
157161
}),
158162
async (args, ctx) => {
159163
const workspace = ctx.workspaceMgr.get(args.workspaceId);
@@ -204,6 +208,7 @@ registerCommand(
204208
cwd,
205209
cols: args.cols ?? 120,
206210
rows: args.rows ?? 30,
211+
themeBackground: args.themeBackground,
207212
});
208213

209214
return terminal;

packages/server/src/session/manager.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export interface CreateSessionRequest {
3131
providerId: string;
3232
provider: ProviderDefinition;
3333
draft?: string;
34+
/**
35+
* Hex color of the xterm.js theme background that will render this
36+
* session's terminal. Forwarded to TerminalSpec.themeBackground so the
37+
* PTY env can advertise COLORFGBG to the agent CLI (see TerminalManager).
38+
*/
39+
themeBackground?: string;
3440
}
3541

3642
export interface SessionLogger {
@@ -249,6 +255,7 @@ export class SessionManager {
249255
CODER_STUDIO_SESSION_ID: sessionId,
250256
},
251257
title: req.provider.displayName,
258+
themeBackground: req.themeBackground,
252259
};
253260

254261
// Create terminal (delegates to TerminalManager)

packages/server/src/terminal/manager.test.ts

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { DomainEvent, Terminal } from "@coder-studio/core";
44
import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";
55
import { EventBus } from "../bus/event-bus";
6-
import { TerminalManager } from "./manager";
6+
import { computeColorFgBg, TerminalManager } from "./manager";
77
import { RingBuffer } from "./ring-buffer";
88
import * as snapshotBufferModule from "./terminal-snapshot-buffer";
99
import type { PtyHost, PtyProcess, TerminalDatabase, TerminalSpec } from "./types";
@@ -164,6 +164,116 @@ describe("TerminalManager", () => {
164164
expect(spawnOptions.env.FORCE_COLOR).toBe("0");
165165
});
166166

167+
it("injects COLORFGBG=0;15 for a light themeBackground", () => {
168+
const spec: TerminalSpec = {
169+
workspaceId: "ws-123",
170+
kind: "shell",
171+
argv: ["bash"],
172+
cwd: "/home/user",
173+
themeBackground: "#fcfffd",
174+
};
175+
176+
manager.create(spec);
177+
178+
const spawnOptions = (mockPtyHost.spawn as Mock).mock.calls[0][1];
179+
expect(spawnOptions.env.COLORFGBG).toBe("0;15");
180+
});
181+
182+
it("injects COLORFGBG=15;0 for a dark themeBackground", () => {
183+
const spec: TerminalSpec = {
184+
workspaceId: "ws-123",
185+
kind: "shell",
186+
argv: ["bash"],
187+
cwd: "/home/user",
188+
themeBackground: "#0b1218",
189+
};
190+
191+
manager.create(spec);
192+
193+
const spawnOptions = (mockPtyHost.spawn as Mock).mock.calls[0][1];
194+
expect(spawnOptions.env.COLORFGBG).toBe("15;0");
195+
});
196+
197+
it("omits COLORFGBG when themeBackground is not provided", () => {
198+
const spec: TerminalSpec = {
199+
workspaceId: "ws-123",
200+
kind: "shell",
201+
argv: ["bash"],
202+
cwd: "/home/user",
203+
};
204+
205+
manager.create(spec);
206+
207+
const spawnOptions = (mockPtyHost.spawn as Mock).mock.calls[0][1];
208+
expect(spawnOptions.env.COLORFGBG).toBeUndefined();
209+
});
210+
211+
it("omits COLORFGBG when themeBackground is malformed", () => {
212+
const spec: TerminalSpec = {
213+
workspaceId: "ws-123",
214+
kind: "shell",
215+
argv: ["bash"],
216+
cwd: "/home/user",
217+
themeBackground: "not-a-color",
218+
};
219+
220+
manager.create(spec);
221+
222+
const spawnOptions = (mockPtyHost.spawn as Mock).mock.calls[0][1];
223+
expect(spawnOptions.env.COLORFGBG).toBeUndefined();
224+
});
225+
226+
it("lets spec.env override the derived COLORFGBG when explicitly set", () => {
227+
const spec: TerminalSpec = {
228+
workspaceId: "ws-123",
229+
kind: "shell",
230+
argv: ["bash"],
231+
cwd: "/home/user",
232+
themeBackground: "#fcfffd",
233+
env: {
234+
COLORFGBG: "7;0",
235+
},
236+
};
237+
238+
manager.create(spec);
239+
240+
const spawnOptions = (mockPtyHost.spawn as Mock).mock.calls[0][1];
241+
expect(spawnOptions.env.COLORFGBG).toBe("7;0");
242+
});
243+
});
244+
245+
describe("computeColorFgBg", () => {
246+
it.each([
247+
["#ffffff", "0;15"],
248+
["#fcfffd", "0;15"],
249+
["#f5f7fa", "0;15"],
250+
["#fff", "0;15"],
251+
])("returns 0;15 (light bg) for %s", (input, expected) => {
252+
expect(computeColorFgBg(input)).toBe(expected);
253+
});
254+
255+
it.each([
256+
["#000000", "15;0"],
257+
["#0b1218", "15;0"],
258+
["#2e3440", "15;0"],
259+
["#000", "15;0"],
260+
])("returns 15;0 (dark bg) for %s", (input, expected) => {
261+
expect(computeColorFgBg(input)).toBe(expected);
262+
});
263+
264+
it("accepts #RRGGBBAA and ignores the alpha channel", () => {
265+
expect(computeColorFgBg("#ffffff80")).toBe("0;15");
266+
expect(computeColorFgBg("#00000080")).toBe("15;0");
267+
});
268+
269+
it("returns undefined for malformed input", () => {
270+
expect(computeColorFgBg("")).toBeUndefined();
271+
expect(computeColorFgBg("white")).toBeUndefined();
272+
expect(computeColorFgBg("#xyz")).toBeUndefined();
273+
expect(computeColorFgBg("#12")).toBeUndefined();
274+
expect(computeColorFgBg("rgb(0,0,0)")).toBeUndefined();
275+
});
276+
167277
it("should throw error on spawn failure", () => {
168278
const spawnError = new Error("Command not found");
169279
mockPtyHost.spawn = vi.fn().mockImplementation(() => {

packages/server/src/terminal/manager.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,59 @@ function isTerminalTraceEnabled(): boolean {
2222
return process.env.CODER_STUDIO_TERMINAL_TRACE === "1";
2323
}
2424

25+
/**
26+
* Parse a #RRGGBB / #RRGGBBAA / #RGB hex color into 8-bit RGB.
27+
* Returns null for any unrecognized input so callers can skip injection.
28+
*/
29+
function parseHexColor(input: string): { r: number; g: number; b: number } | null {
30+
const trimmed = input.trim();
31+
if (!trimmed.startsWith("#")) {
32+
return null;
33+
}
34+
35+
const hex = trimmed.slice(1);
36+
let r: number;
37+
let g: number;
38+
let b: number;
39+
if (hex.length === 3) {
40+
r = Number.parseInt(hex[0]! + hex[0]!, 16);
41+
g = Number.parseInt(hex[1]! + hex[1]!, 16);
42+
b = Number.parseInt(hex[2]! + hex[2]!, 16);
43+
} else if (hex.length === 6 || hex.length === 8) {
44+
r = Number.parseInt(hex.slice(0, 2), 16);
45+
g = Number.parseInt(hex.slice(2, 4), 16);
46+
b = Number.parseInt(hex.slice(4, 6), 16);
47+
} else {
48+
return null;
49+
}
50+
51+
if ([r, g, b].some((v) => Number.isNaN(v))) {
52+
return null;
53+
}
54+
return { r, g, b };
55+
}
56+
57+
/**
58+
* Derive an xterm-style COLORFGBG value from a theme background hex color.
59+
*
60+
* Returns "0;15" (dark text on light background) when the perceived luma is
61+
* above the threshold, "15;0" (light text on dark background) otherwise.
62+
* Returns undefined when the input cannot be parsed so the caller leaves the
63+
* variable untouched and the child TUI falls back to its own default.
64+
*
65+
* Luma uses the Rec. 601 coefficients; the threshold is 0.5 which is the same
66+
* cutoff most TUIs (Ink, chalk, bat, delta) use internally.
67+
*/
68+
export function computeColorFgBg(background: string): string | undefined {
69+
const rgb = parseHexColor(background);
70+
if (!rgb) {
71+
return undefined;
72+
}
73+
74+
const luma = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
75+
return luma > 0.5 ? "0;15" : "15;0";
76+
}
77+
2578
function countOccurrences(text: string, needle: string): number {
2679
return text.split(needle).length - 1;
2780
}
@@ -109,13 +162,24 @@ export class TerminalManager {
109162
// FORCE_COLOR makes agent CLIs that are spawned directly (e.g. Claude Code
110163
// via Ink/chalk, Codex) emit ANSI colors without relying on the user's
111164
// shell rc to set it. spec.env can still override explicitly.
165+
//
166+
// COLORFGBG advertises the frontend xterm theme's light/dark intent so
167+
// TUIs that auto-pick color schemes (Claude Code, Codex, bat, delta, …)
168+
// can match the page background. We derive it from spec.themeBackground.
169+
// On Windows the ConPTY layer intercepts OSC 11 background-color queries
170+
// and never forwards xterm.js's response back to the child, so this env
171+
// variable is the only signal that survives the round trip.
172+
const derivedColorFgBg = spec.themeBackground
173+
? computeColorFgBg(spec.themeBackground)
174+
: undefined;
112175
const terminalEnv: Record<string, string> = {
113176
...Object.fromEntries(
114177
Object.entries(process.env).filter((e): e is [string, string] => e[1] != null)
115178
),
116179
TERM: "xterm-256color",
117180
COLORTERM: "truecolor",
118181
FORCE_COLOR: "3",
182+
...(derivedColorFgBg ? { COLORFGBG: derivedColorFgBg } : {}),
119183
...spec.env,
120184
};
121185

packages/server/src/terminal/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ export interface TerminalSpec {
1414
cols?: number;
1515
rows?: number;
1616
title?: string;
17+
/**
18+
* Hex color (e.g. "#fcfffd") of the frontend xterm.js theme background that
19+
* will render this terminal. The TerminalManager uses it to derive
20+
* COLORFGBG so child TUIs (Claude Code, Codex, …) can pick a matching
21+
* light/dark color scheme. This is the only signal that survives the
22+
* Windows ConPTY layer, which otherwise intercepts OSC 11 background
23+
* queries and forces TUIs into their dark fallback.
24+
*/
25+
themeBackground?: string;
1726
}
1827

1928
/**

packages/web/src/features/agent-panes/actions/use-provider-launcher.test.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,14 @@ describe("useProviderLauncher", () => {
169169
await waitFor(() => {
170170
expect(dispatch).toHaveBeenCalledWith("provider.install.get", { jobId: "job-1" });
171171
expect(dispatch).toHaveBeenCalledWith("provider.runtimeStatus", {});
172-
expect(dispatch).toHaveBeenCalledWith("session.create", {
173-
workspaceId: "ws-1",
174-
providerId: "claude",
175-
});
172+
expect(dispatch).toHaveBeenCalledWith(
173+
"session.create",
174+
expect.objectContaining({
175+
workspaceId: "ws-1",
176+
providerId: "claude",
177+
themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/),
178+
})
179+
);
176180
});
177181

178182
expect(onSessionCreated).toHaveBeenCalledWith(
@@ -278,10 +282,14 @@ describe("useProviderLauncher", () => {
278282

279283
await waitFor(() => {
280284
expect(dispatch).toHaveBeenCalledWith("provider.runtimeStatus", {});
281-
expect(dispatch).toHaveBeenCalledWith("session.create", {
282-
workspaceId: "ws-1",
283-
providerId: "claude",
284-
});
285+
expect(dispatch).toHaveBeenCalledWith(
286+
"session.create",
287+
expect.objectContaining({
288+
workspaceId: "ws-1",
289+
providerId: "claude",
290+
themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/),
291+
})
292+
);
285293
expect(result.current.states.claude.runtime?.available).toBe(false);
286294
expect(result.current.states.claude.inlineError).toBe("Claude CLI is missing");
287295
});

packages/web/src/features/agent-panes/actions/use-provider-launcher.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
} from "@coder-studio/core";
88
import { useEffect, useRef, useState } from "react";
99
import type { DispatchCommand } from "../../../atoms/connection";
10+
import { useTerminalThemeBackground } from "../../../theme";
1011

1112
export type ProviderId = "claude" | "codex";
1213

@@ -65,6 +66,7 @@ export function useProviderLauncher(
6566
): UseProviderLauncherResult {
6667
const [states, setStates] = useState<Record<ProviderId, ProviderCardState>>(buildStateMap());
6768
const pollingTimers = useRef<Partial<Record<ProviderId, number>>>({});
69+
const themeBackground = useTerminalThemeBackground();
6870

6971
useEffect(() => {
7072
let cancelled = false;
@@ -190,6 +192,7 @@ export function useProviderLauncher(
190192
const createResult = await dispatch<Session>("session.create", {
191193
workspaceId,
192194
providerId,
195+
themeBackground,
193196
});
194197

195198
if (createResult.ok && createResult.data) {
@@ -259,6 +262,7 @@ export function useProviderLauncher(
259262
const createResult = await dispatch<Session>("session.create", {
260263
workspaceId,
261264
providerId,
265+
themeBackground,
262266
});
263267

264268
if (createResult.ok && createResult.data) {
@@ -315,6 +319,7 @@ export function useProviderLauncher(
315319
const createResult = await dispatch<Session>("session.create", {
316320
workspaceId,
317321
providerId,
322+
themeBackground,
318323
});
319324

320325
if (createResult.ok && createResult.data) {

packages/web/src/features/agent-panes/components/session-card.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,11 @@ describe("SessionCard", () => {
163163
await waitFor(() => {
164164
expect(sendCommand).toHaveBeenCalledWith(
165165
"session.create",
166-
{
166+
expect.objectContaining({
167167
workspaceId: "ws-123",
168168
providerId: "codex",
169-
},
169+
themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/),
170+
}),
170171
undefined
171172
);
172173
});

packages/web/src/features/agent-panes/index.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -760,10 +760,11 @@ describe("AgentPanes", () => {
760760
await waitFor(() => {
761761
expect(sendCommand).toHaveBeenCalledWith(
762762
"session.create",
763-
{
763+
expect.objectContaining({
764764
workspaceId: "ws-1",
765765
providerId: "claude",
766-
},
766+
themeBackground: expect.stringMatching(/^#[0-9a-fA-F]{6,8}$/),
767+
}),
767768
undefined
768769
);
769770
});

0 commit comments

Comments
 (0)