authorgravatar for psv2522@gmail.comPrathmesh Vhatkar <psv2522@gmail.com> 2026-10-12 10:19:25+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 21:49:25-07:00
log37e2acda5118d13c144efb009d4911ff8f65d6ed
tree6d2d4dbe5f19a89251a3731c1c95dee0c182838d
parent19bef8c1d5724953f8866b19e9e8c382bd99315f
signature Signed by PGP key B5690EEEBB952194

fix(web): terminal colors no longer look washed out (#17168)


3 files changed, 81 insertions(+), 0 deletions(-)

apps/web/src/components/ThreadTerminalDrawer.tsx+46
...@@ -158,6 +158,51 @@ function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor...@@ -158,6 +158,51 @@ function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor
158 };158 };
159}159}
160160
161function hexToGhosttyColor(hex: string): GhosttyColor {
162 const value = Number.parseInt(hex.slice(1), 16);
163 return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
164}
165
166// VS Code's default ANSI colors.
167const TERMINAL_PALETTES = {
168 light: [
169 "#000000",
170 "#cd3131",
171 "#107c10",
172 "#949800",
173 "#0451a5",
174 "#bc05bc",
175 "#0598bc",
176 "#555555",
177 "#666666",
178 "#cd3131",
179 "#14ce14",
180 "#b5ba00",
181 "#0451a5",
182 "#bc05bc",
183 "#0598bc",
184 "#a5a5a5",
185 ].map(hexToGhosttyColor),
186 dark: [
187 "#000000",
188 "#cd3131",
189 "#0dbc79",
190 "#e5e510",
191 "#2472c8",
192 "#bc3fbc",
193 "#11a8cd",
194 "#e5e5e5",
195 "#666666",
196 "#f14c4c",
197 "#23d18b",
198 "#f5f543",
199 "#3b8eea",
200 "#d670d6",
201 "#29b8db",
202 "#e5e5e5",
203 ].map(hexToGhosttyColor),
204};
205
161function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string {206function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string {
162 if (!runtimeEnv) return "";207 if (!runtimeEnv) return "";
163 return JSON.stringify(208 return JSON.stringify(
...@@ -249,6 +294,7 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty...@@ -249,6 +294,7 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty
249 terminalCursor,294 terminalCursor,
250 isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 },295 isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 },
251 ),296 ),
297 palette: TERMINAL_PALETTES[isDark ? "dark" : "light"],
252 selectionBackground: terminalSelection,298 selectionBackground: terminalSelection,
253 };299 };
254}300}
apps/web/src/terminal/ghostty/core.test.ts+20
...@@ -174,6 +174,26 @@ describe("GhosttyTerminalCore snapshots", () => {...@@ -174,6 +174,26 @@ describe("GhosttyTerminalCore snapshots", () => {
174 ]);174 ]);
175 });175 });
176176
177 it("paints ANSI colors from the theme palette and repaints them on theme change", async () => {
178 const core = await createCore();
179 const theme = {
180 foreground: { r: 255, g: 255, b: 255 },
181 background: { r: 0, g: 0, b: 0 },
182 cursor: { r: 255, g: 255, b: 255 },
183 };
184 const palette = Array.from({ length: 16 }, (_, index) => ({ r: index, g: 1, b: 2 }));
185 core.write("\x1b[31mR\x1b[38;5;196mX\x1b[0m");
186 core.setTheme({ ...theme, palette });
187 let cells = core.snapshot().rowData[0]!.cells;
188 expect(cells[0]!.foreground).toEqual({ r: 1, g: 1, b: 2 });
189 expect(cells[1]!.foreground).toEqual({ r: 255, g: 0, b: 0 });
190
191 core.setTheme({ ...theme, palette: palette.map((color) => ({ ...color, g: 9 })) });
192 cells = core.snapshot().rowData[0]!.cells;
193 expect(cells[0]!.foreground).toEqual({ r: 1, g: 9, b: 2 });
194 expect(cells[1]!.foreground).toEqual({ r: 255, g: 0, b: 0 });
195 });
196
177 it("reuses a grown grapheme buffer and releases it on disposal", async () => {197 it("reuses a grown grapheme buffer and releases it on disposal", async () => {
178 const core = await createCore();198 const core = await createCore();
179 const runtime = await loadGhosttyRuntime();199 const runtime = await loadGhosttyRuntime();
apps/web/src/terminal/ghostty/core.ts+15
...@@ -13,6 +13,7 @@ const MAX_SCROLLBACK_ROWS = 10_000;...@@ -13,6 +13,7 @@ const MAX_SCROLLBACK_ROWS = 10_000;
13// wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the13// wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the
14// libghostty-vt revision pinned alongside this module.14// libghostty-vt revision pinned alongside this module.
15const SELECTION_FORMAT_OPTIONS_SIZE = 16;15const SELECTION_FORMAT_OPTIONS_SIZE = 16;
16const PALETTE_BYTES = 256 * 3;
1617
17const RENDER_DATA = {18const RENDER_DATA = {
18 cols: 1,19 cols: 1,
...@@ -81,6 +82,7 @@ export interface GhosttyTheme {...@@ -81,6 +82,7 @@ export interface GhosttyTheme {
81 readonly foreground: GhosttyColor;82 readonly foreground: GhosttyColor;
82 readonly background: GhosttyColor;83 readonly background: GhosttyColor;
83 readonly cursor: GhosttyColor;84 readonly cursor: GhosttyColor;
85 readonly palette?: readonly GhosttyColor[];
84 /** CSS color the renderer overlays on selected cells; not sent to Ghostty. */86 /** CSS color the renderer overlays on selected cells; not sent to Ghostty. */
85 readonly selectionBackground?: string;87 readonly selectionBackground?: string;
86}88}
...@@ -415,6 +417,19 @@ export class GhosttyTerminalCore {...@@ -415,6 +417,19 @@ export class GhosttyTerminalCore {
415 this.runtime.call("ghostty_terminal_set", this.terminal, option, color);417 this.runtime.call("ghostty_terminal_set", this.terminal, option, color);
416 }418 }
417 this.runtime.free(color, 3);419 this.runtime.free(color, 3);
420 if (theme.palette) this.setPalette(theme.palette);
421 }
422
423 private setPalette(colors: readonly GhosttyColor[]): void {
424 // Ghostty takes all 256 entries; keep its defaults for 16-255.
425 const palette = this.runtime.alloc(PALETTE_BYTES);
426 this.runtime.call("ghostty_terminal_get", this.terminal, 25, palette);
427 const bytes = this.runtime.bytes(palette, PALETTE_BYTES);
428 colors.slice(0, 16).forEach((color, index) => {
429 bytes.set([color.r, color.g, color.b], index * 3);
430 });
431 this.runtime.call("ghostty_terminal_set", this.terminal, 14, palette);
432 this.runtime.free(palette, PALETTE_BYTES);
418 }433 }
419434
420 scroll(deltaRows: number): void {435 scroll(deltaRows: number): void {