| author | |
| committer | |
| log | 37e2acda5118d13c144efb009d4911ff8f65d6ed |
| tree | 6d2d4dbe5f19a89251a3731c1c95dee0c182838d |
| parent | 19bef8c1d5724953f8866b19e9e8c382bd99315f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | } |
| 160 | 160 | ||
| 161 | function 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. | ||
| 167 | const 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 | |||
| 161 | function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string { | 206 | function 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 | }); |
| 176 | 176 | ||
| 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 the | 13 | // wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the |
| 14 | // libghostty-vt revision pinned alongside this module. | 14 | // libghostty-vt revision pinned alongside this module. |
| 15 | const SELECTION_FORMAT_OPTIONS_SIZE = 16; | 15 | const SELECTION_FORMAT_OPTIONS_SIZE = 16; |
| 16 | const PALETTE_BYTES = 256 * 3; | ||
| 16 | 17 | ||
| 17 | const RENDER_DATA = { | 18 | const 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 | } |
| 419 | 434 | ||
| 420 | scroll(deltaRows: number): void { | 435 | scroll(deltaRows: number): void { |