Skip to content

Conversation

@bresilla
Copy link

@bresilla bresilla commented Jan 20, 2026

Summary

Adds full support for terminal indexed colors (0-255) and OSC-based terminal palette control, enabling the use of the standard 256-color palette and dynamic color modification via ANSI/OSC escape sequences.

Makes this possible (with tools like pywal et al):
ezgif-54dccc5a972ae0ee

Changes

TypeScript (RGBA.ts)

  • Added COLOR_TYPE_RGB, COLOR_TYPE_INDEXED, COLOR_TYPE_DEFAULT constants
  • Extended RGBA class with colorType, index properties
  • Added RGBA.fromIndex(index), RGBA.defaultForeground(), RGBA.defaultBackground() static methods
  • Added isIndexed(), isDefault(), isRgb() instance methods
  • Added indexToApproximateRgb() function with standard 256-color palette mapping
  • Extended ColorInput type to accept string | RGBA | IndexedColor | number
  • Updated parseColor() to handle "ansi:N" format, numeric indices, and {index: N} objects

TypeScript (terminal-palette.ts) - OSC Palette Control

  • Added setPaletteColor(index, hex) - dynamically set any of the 256 palette colors (OSC 4)
  • Added setForeground(hex) - set terminal default foreground color (OSC 10)
  • Added setBackground(hex) - set terminal default background color (OSC 11)
  • Added setCursorColor(hex) - set cursor color (OSC 12)
  • Added resetPaletteColor(index) - reset palette color to terminal default (OSC 104)
  • Added resetForeground() - reset foreground to default (OSC 110)
  • Added resetBackground() - reset background to default (OSC 111)
  • Added resetCursorColor() - reset cursor color to default (OSC 112)

Zig (ansi.zig)

  • Added ColorType enum: rgb, indexed, default
  • Added Color struct combining RGBA with type and index
  • Added indexToApproximateRgba(), fgIndexedColorOutput(), bgIndexedColorOutput()
  • Added fgDefaultOutput(), bgDefaultOutput() for default color reset

Zig (buffer.zig)

  • Extended Cell struct with fg_color_type, bg_color_type, fg_index, bg_index
  • Added corresponding arrays to OptimizedBuffer
  • Updated all buffer operations to handle color types

Zig (renderer.zig)

  • Updated color output to check cell color type
  • Outputs \x1b[38;5;Nm / \x1b[48;5;Nm for indexed colors
  • Outputs \x1b[39m / \x1b[49m for default colors
  • Falls back to RGB for rgb color type

Usage Examples

Indexed Colors

import { parseColor, RGBA } from "@opentui/core"

// Various ways to specify indexed colors
parseColor("ansi:196")      // String format (bright red)
parseColor(46)              // Numeric (green)
parseColor({ index: 226 })  // Object (yellow)
RGBA.fromIndex(21)          // Direct method (blue)

// Check color type
const color = parseColor("ansi:196")
color.isIndexed()  // true
color.index        // 196

OSC Palette Control

// Dynamically modify terminal colors
paletteDetector.setPaletteColor(1, "#ff5555")  // Change color 1 to custom red
paletteDetector.setBackground("#1a1a2e")       // Set terminal background
paletteDetector.setForeground("#eaeaea")       // Set terminal foreground
paletteDetector.setCursorColor("#ff79c6")      // Set cursor color

// Reset to terminal defaults
paletteDetector.resetPaletteColor(1)           // Reset color 1
paletteDetector.resetBackground()              // Reset background
paletteDetector.resetForeground()              // Reset foreground

Testing

  • All 3057 existing tests pass
  • Build completes successfully

- Extend RGBA class with colorType, index properties and fromIndex() method
- Add parseColor() support for 'ansi:N' format, numeric indices, and {index: N} objects
- Add indexToApproximateRgb() for converting indexed colors to RGB approximations
- Extend Zig Cell structure with fg/bg color type and index tracking
- Add indexed color ANSI escape sequence output (ESC[38;5;Nm / ESC[48;5;Nm)
- Add default color support (ESC[39m / ESC[49m)
- Add terminal palette set/reset methods for OSC 4/10/11/12 color control
Copilot AI review requested due to automatic review settings January 20, 2026 14:41
@bresilla bresilla requested a review from Hona as a code owner January 20, 2026 14:41
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds support for terminal indexed colors (0-255) and OSC-based palette control to enable dynamic terminal color modification. However, the implementation has a critical architectural issue: the color type metadata (indexed vs RGB vs default) is not transmitted across the FFI boundary between TypeScript and Zig, rendering the indexed color feature non-functional in practice.

Changes:

  • Extended RGBA class with color type tracking (indexed, RGB, default) and added parsing support for numeric indices and "ansi:N" format
  • Added OSC palette control methods for dynamically setting and resetting terminal colors
  • Extended Zig buffer Cell struct with color type fields and updated renderer to output appropriate ANSI sequences based on color type

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
packages/core/src/lib/RGBA.ts Added color type tracking, indexed color support, and extended ColorInput type
packages/core/src/lib/terminal-palette.ts Added OSC sequence methods for setting/resetting palette colors
packages/core/src/renderables/EditBufferRenderable.ts Updated type annotations to use expanded ColorInput type
packages/core/src/zig/ansi.zig Added ColorType enum, Color struct, and indexed color output functions
packages/core/src/zig/buffer.zig Extended Cell and OptimizedBuffer with color type fields and added setCellWithColorType method
packages/core/src/zig/renderer.zig Updated rendering logic to output indexed/default color ANSI codes based on cell color type
Comments suppressed due to low confidence (1)

packages/core/src/zig/buffer.zig:925

  • When creating Cell structs in drawChar and setCellWithAlphaBlending, the color type fields are not specified, causing them to default to .rgb with index 0. This means indexed and default colors passed from TypeScript will be converted to RGB during rendering. These methods need to either receive color type parameters or extract them from an extended FFI interface.
    pub fn drawChar(
        self: *OptimizedBuffer,
        char: u32,
        x: u32,
        y: u32,
        fg: RGBA,
        bg: RGBA,
        attributes: u32,
    ) !void {
        if (!self.isPointInScissor(@intCast(x), @intCast(y))) return;

        if (isRGBAWithAlpha(bg) or isRGBAWithAlpha(fg)) {
            try self.setCellWithAlphaBlending(x, y, char, fg, bg, attributes);
        } else {
            self.set(x, y, Cell{
                .char = char,
                .fg = fg,
                .bg = bg,
                .attributes = attributes,
            });
        }
    }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

… add tests

- Add FFI functions to pass color type + index metadata to Zig
- Update buffer.ts to use new FFI when colors have type info
- Add parseColor context param for correct default fg/bg handling
- Add hex validation in normalizeHex() with clear error messages
- Remove unused Zig Color struct
- Add tests for OSC palette methods and indexed color parsing
Keep colorType/index through styled text, box borders/fills, and renderer background so ANSI palette theming (e.g. pywal) works consistently.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 12 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Normalize colorType/index before passing to Zig and add RGBA coverage for default/indexed inputs.
@msmps msmps added the core This relates to the core package label Jan 21, 2026
@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 23, 2026

@opentui/core

npm i https://pkg.pr.new/anomalyco/opentui/@opentui/core@559

@opentui/react

npm i https://pkg.pr.new/anomalyco/opentui/@opentui/react@559

@opentui/solid

npm i https://pkg.pr.new/anomalyco/opentui/@opentui/solid@559

@opentui/core-darwin-arm64

npm i https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-arm64@559

@opentui/core-darwin-x64

npm i https://pkg.pr.new/anomalyco/opentui/@opentui/core-darwin-x64@559

@opentui/core-linux-arm64

npm i https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-arm64@559

@opentui/core-linux-x64

npm i https://pkg.pr.new/anomalyco/opentui/@opentui/core-linux-x64@559

@opentui/core-win32-arm64

npm i https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-arm64@559

@opentui/core-win32-x64

npm i https://pkg.pr.new/anomalyco/opentui/@opentui/core-win32-x64@559

commit: c23bfc4

@@ -1,8 +1,16 @@
export const COLOR_TYPE_RGB = 0
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would make this an enum.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed an enum would be cleaner. I didn’t change it in this PR because those values cross the TS↔Zig FFI boundary (0/1/2) and we’re already touching a lot of call sites/ABI surfaces here; keeping plain numeric constants reduced churn and made it easier to verify we stayed aligned with Zig. If you’d like, I can do a small follow-up commit that introduces a ColorType enum (backed by the same 0/1/2 values) and updates the internal TS usages without changing the FFI encoding.

}

export function parseColor(color: ColorInput): RGBA {
export function parseColor(color: ColorInput, context: "fg" | "bg" = "fg"): RGBA {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseColor is used in so many places... I currently don't think it should be context aware.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: parseColor is stateless again. Added parseFgColor() / parseBgColor() helpers so only callers that need bg semantics opt in, while the default API remains unchanged.

setPaletteColor(index: number, hex: string): void
setForeground(hex: string): void
setBackground(hex: string): void
setCursorColor(hex: string): void
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a method on renderer already to set the cursor color I think. That would be duplicated.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — agreed. The renderer already exposes cursor color control (CliRenderer.setCursorColor(...)), so I removed setCursorColor/resetCursorColor from TerminalPalette (and updated the related tests) to avoid duplicating the API surface.

@kommander
Copy link
Collaborator

Thanks for taking a stab at this. Having RGBA with additional attributes for type and index it adds some overhead in passing it around. When I was thinking about how to do this I thought it could maybe be added as additional f32, making it RGBA+Attributes, where the last f32 can be used to encode type + index and use bitmasks to encode/decode. I would make framebuffers use slightly more memory, but most apps don't use dozens of large framebuffers, so mostly negligible.

I'd like to try and keep the API surface impact as small as possible, like keeping parseColor stateless and not context aware.

* upstream/main:
  Fix node using itself as anchor resulting in incorrect ordering (anomalyco#552)
  chore(core): add Code and ScrollBox construct helpers (anomalyco#565)
  fix(react): share app context across build entrypoints (anomalyco#571)
  feat(core): add SIGPIPE, SIGBUS, SIGFPE to default exit signals (anomalyco#547)
  fix(react/solid): add missing markdown element (anomalyco#569)
Replace parseColor fg/bg context arg with parseFgColor/parseBgColor helpers and remove TerminalPalette cursor color methods to avoid duplicating renderer API.
@bresilla
Copy link
Author

Thanks for taking a stab at this. Having RGBA with additional attributes for type and index it adds some overhead in passing it around. When I was thinking about how to do this I thought it could maybe be added as additional f32, making it RGBA+Attributes, where the last f32 can be used to encode type + index and use bitmasks to encode/decode. I would make framebuffers use slightly more memory, but most apps don't use dozens of large framebuffers, so mostly negligible.

I'd like to try and keep the API surface impact as small as possible, like keeping parseColor stateless and not context aware.

I updated things so parseColor is stateless again. Callers that need “default background” semantics now opt into parseBgColor() (and parseFgColor() for symmetry), so we no longer have to thread a context parameter through the entire codebase.

On the representation: I agree that an RGBA + meta packed float (or similar) would be a nice improvement to reduce object metadata overhead and allow the color type/index to travel naturally with the buffer. That’s a larger refactor—it would touch FFI structs, buffer layout, and a fair bit of Zig/TS packing code—so I kept this PR focused on correctness and on wiring the metadata through all render paths.

I can follow up with a separate PR (maybe??) that explores the packed-f32 approach while keeping the external API stable.

@kommander
Copy link
Collaborator

It's still alpha, so I wouldn't mind breaking some APIs at this stage to get it "right". But also, what high level APIs would break? RGBA interfaces wouldn't have to change with defaults I think? And passing RGBA around should then basically be the same.

@bresilla
Copy link
Author

It's still alpha, so I wouldn't mind breaking some APIs at this stage to get it "right". But also, what high level APIs would break? RGBA interfaces wouldn't have to change with defaults I think? And passing RGBA around should then basically be the same.

Totally fair — if we’re willing to break some APIs now, doing the packed RGBA + meta is probably the cleanest long-term shape.
But actually we don't have to have high-level API breakage - if we’re careful:

  • We can keep the public RGBA class surface the same (fromValues/fromHex/fromIndex/defaultForeground/defaultBackground, isRgb/isIndexed/isDefault, index, etc).
  • Passing RGBA around in TS stays basically the same.

What would likely change is more “internal/FFI plumbing” than user-facing APIs:

  • Any code that assumes RGBA.buffer is exactly 4 floats would need updating (it may become 5 floats or a different encoding).
  • All Zig FFI entrypoints that take [*]const f32 expecting RGBA(4) would need to accept the new layout (or we keep passing RGBA(4) plus separate u8s, as now).
  • Any helper that directly manipulates framebuffer arrays (fg/bg buffers) might need to allocate/handle the extra metadata lane.

@bresilla
Copy link
Author

I think this PR can merge as-is for correctness, and then if you’re happy with the direction, I can open a small follow-up PR that experiments with the packed RGBA+meta representation (bit-packing type/index into an extra float). The aim would be to keep the public RGBA/parseColor APIs the same and confine other bits to internal buffers + TS↔Zig plumbing.

@bresilla
Copy link
Author

bresilla commented Jan 23, 2026

Looking at this deeper, "a safer phased plan” -so to say- is if we do it later as a follow-up PR with those stages (maybe!) - and is becoming a large PR as it is...:

Step 1 (no buffer layout change yet)

  • Introduce a ColorType enum in TS + Zig helper functions to standardize meta.
  • Keep .buffer at 4 floats, keep passing meta u8s separately (as we do now), just refactor for clarity.

Step2 (packed meta, but keep separate arrays)

  • Make RGBA.buffer 5 floats and encode meta in float #5.
  • In FFI, decode meta once at entrypoints and still fill fg_color_type/index arrays (so internal Zig logic stays mostly same).
  • This isolates changes to FFI boundary + RGBA class, not the whole renderer.

Step 3 (maybe some optimization)

  • Remove separate arrays and store packed meta directly in the buffer... (?)

@kommander
Copy link
Collaborator

That could make sense to do it step by step. @simonklee what do you think?

@bresilla there is some segfault in the tests currently though.

Allow SyntaxStyle.fromStyles to accept ColorInput while normalizing to RGBA for FFI safety, and guard FFI color metadata against non-RGBA inputs.
@bresilla bresilla requested a review from simonklee as a code owner January 24, 2026 16:32
@bresilla
Copy link
Author

bresilla commented Jan 24, 2026

That could make sense to do it step by step. @simonklee what do you think?

@bresilla there is some segfault in the tests currently though.

Sorry — I was initially only running core tests. Should be fixed now...

Anyway, Solid test use SyntaxStyle.fromStyles often and are called with string colors (e.g. fg: "#C792EA"). Normalizing fg/bg inside registerStyle keeps that API working while ensuring only RGBA reaches Zig, preventing runtime crashes and keeping internal types consistent.

Edit: still not passing, gonna check it later today.
Sorry for the mess 😅

Edit2: I think the error is not related to the code i added... I think. Sorry not very good at js/ts/bun things. But according to my understanding the error is about bun version bla bla bla... :P

Copy link
Collaborator

@simonklee simonklee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general its hard to know if all edge-cases have been covered because there are many of these draw paths. I walked through a couple and found a few that still bypass the new colorType/index
handling. I don't know that tests are good enough for us to catch all of these either.

}

// Grayscale (232-255)
const gray = (index - 232) * (1.0 / 23)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

xterm grayscale uses values 8, 18, ... 238 (steps of 10)

const gray = (8 + (index - 232) * 10) / 255

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just following this:

image

which is from ANSI Escape Colors

or a i misunderstanding something??

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

20260125_15h19m11s_grim From the wiki article `level = gray * 10 + 8`

const rowSliceAttrs = self.buffer.attributes[rowStartIndex .. rowStartIndex + rowWidth];
const rowSliceBgType = self.buffer.bg_color_type[rowStartIndex .. rowStartIndex + rowWidth];
const rowSliceBgIndex = self.buffer.bg_index[rowStartIndex .. rowStartIndex + rowWidth];

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't we missing the fg meta in fast path here?

const rowSliceFgType = self.buffer.fg_color_type[rowStartIndex .. rowStartIndex + rowWidth];
const rowSliceFgIndex = self.buffer.fg_index[rowStartIndex .. rowStartIndex + rowWidth];

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, sorry, mistake. Will add that :)

@bresilla
Copy link
Author

In general its hard to know if all edge-cases have been covered because there are many of these draw paths. I walked through a couple and found a few that still bypass the new colorType/index handling. I don't know that tests are good enough for us to catch all of these either.

I will try to grep and check as much as i can, TS is not my language though... soo it's a bit tricky to follow :P

@kommander
Copy link
Collaborator

We should probably get some of the draw paths test covered more thoroughly and make sure those tests pass on main as well, so we can assure at least somewhat same behaviour.

@kommander kommander added the enhancement New feature or request label Jan 26, 2026
@kommander
Copy link
Collaborator

@bresilla thank you for pushing this forward, I really want to have this. I think we should go through with integrating the color info into the RGBA+A bytes to pass around. I know it's a big undertaking, that's why it wasn't done yet. I get the idea of taking it step by step, but I think the way it's currently done it introduces complexity we have to clean up in the next step, while the first step can be clane and I don't like these spreading through the codebase when the RGBA+A type can encode and transport them as a single unit:

 pub const StyleDefinition = struct {
     fg: ?RGBA,
     bg: ?RGBA,
+    fg_color_type: ansi.ColorType = .rgb,
+    bg_color_type: ansi.ColorType = .rgb,
+    fg_index: u8 = 0,
+    bg_index: u8 = 0,
     attributes: u32,
 };

It should not be necessary to have additional methods like bufferSetCellWithColorType or bufferSetCellWithAlphaBlendingAndColorType to handle things, all existing methods should basically be enough and handle the new RGBA+A type appropriately.

@bresilla
Copy link
Author

@bresilla thank you for pushing this forward, I really want to have this. I think we should go through with integrating the color info into the RGBA+A bytes to pass around. I know it's a big undertaking, that's why it wasn't done yet. I get the idea of taking it step by step, but I think the way it's currently done it introduces complexity we have to clean up in the next step, while the first step can be clane and I don't like these spreading through the codebase when the RGBA+A type can encode and transport them as a single unit:

 pub const StyleDefinition = struct {
     fg: ?RGBA,
     bg: ?RGBA,
+    fg_color_type: ansi.ColorType = .rgb,
+    bg_color_type: ansi.ColorType = .rgb,
+    fg_index: u8 = 0,
+    bg_index: u8 = 0,
     attributes: u32,
 };

It should not be necessary to have additional methods like bufferSetCellWithColorType or bufferSetCellWithAlphaBlendingAndColorType to handle things, all existing methods should basically be enough and handle the new RGBA+A type appropriately.

Hey, sorry for late response.
No worries, I would like to give a try your way. I don't have time coming days, but I am hoping after that I can spend an evening or two on this.

How would you suggest me doing it? Close this and start from scratch? I think that would be the cleanest way...

@kommander
Copy link
Collaborator

I'll pick this up asap, if I get to it before you do I'll take your branch here as a base. I would just work from what we have here now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core This relates to the core package enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants