| 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 | 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 | 206 | function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string { |
| 162 | 207 | if (!runtimeEnv) return ""; |
| 163 | 208 | return JSON.stringify( |
| ... | ... | @@ -249,6 +294,7 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty |
| 249 | 294 | terminalCursor, |
| 250 | 295 | isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, |
| 251 | 296 | ), |
| 297 | palette: TERMINAL_PALETTES[isDark ? "dark" : "light"], | |
| 252 | 298 | selectionBackground: terminalSelection, |
| 253 | 299 | }; |
| 254 | 300 | } |
apps/web/src/terminal/ghostty/core.test.ts+20| ... | ... | @@ -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 | 197 | it("reuses a grown grapheme buffer and releases it on disposal", async () => { |
| 178 | 198 | const core = await createCore(); |
| 179 | 199 | const runtime = await loadGhosttyRuntime(); |
apps/web/src/terminal/ghostty/core.ts+15| ... | ... | @@ -13,6 +13,7 @@ const MAX_SCROLLBACK_ROWS = 10_000; |
| 13 | 13 | // wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the |
| 14 | 14 | // libghostty-vt revision pinned alongside this module. |
| 15 | 15 | const SELECTION_FORMAT_OPTIONS_SIZE = 16; |
| 16 | const PALETTE_BYTES = 256 * 3; | |
| 16 | 17 | |
| 17 | 18 | const RENDER_DATA = { |
| 18 | 19 | cols: 1, |
| ... | ... | @@ -81,6 +82,7 @@ export interface GhosttyTheme { |
| 81 | 82 | readonly foreground: GhosttyColor; |
| 82 | 83 | readonly background: GhosttyColor; |
| 83 | 84 | readonly cursor: GhosttyColor; |
| 85 | readonly palette?: readonly GhosttyColor[]; | |
| 84 | 86 | /** CSS color the renderer overlays on selected cells; not sent to Ghostty. */ |
| 85 | 87 | readonly selectionBackground?: string; |
| 86 | 88 | } |
| ... | ... | @@ -415,6 +417,19 @@ export class GhosttyTerminalCore { |
| 415 | 417 | this.runtime.call("ghostty_terminal_set", this.terminal, option, color); |
| 416 | 418 | } |
| 417 | 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 | 435 | scroll(deltaRows: number): void { |