Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/ansi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const ANSI = {
moveCursorAndClear: (row: number, col: number) => `\x1b[${row};${col}H\x1b[J`,

setRgbBackground: (r: number, g: number, b: number) => `\x1b[48;2;${r};${g};${b}m`,
setAnsi16Background: (index: number) => index < 8 ? `\x1b[${40 + index}m` : `\x1b[${100 + (index - 8)}m`,
setAnsi16Foreground: (index: number) => index < 8 ? `\x1b[${30 + index}m` : `\x1b[${90 + (index - 8)}m`,
resetBackground: "\x1b[49m",

// Bracketed paste mode
Expand Down
35 changes: 34 additions & 1 deletion packages/core/src/lib/RGBA.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export class RGBA {
buffer: Float32Array
/** ANSI 16-color palette index (0-15) when this color was created from a
* standard named color (e.g. "red", "brightcyan"). Undefined for all other
* inputs so callers can fall back to truecolor without a separate flag. */
ansi16Index?: number

constructor(buffer: Float32Array) {
this.buffer = buffer
Expand Down Expand Up @@ -186,6 +190,30 @@ const CSS_COLOR_NAMES: Record<string, string> = {
brightwhite: "#FFFFFF",
}

// Maps the subset of CSS_COLOR_NAMES that correspond to the 16-color ANSI
// palette to their canonical palette index (0 = black … 15 = brightwhite).
// Colors that exist in CSS_COLOR_NAMES but have no ANSI 16 equivalent
// (gray, silver, maroon, olive, …) are intentionally absent so callers
// continue to use truecolor for those inputs.
const ANSI_16_INDEX: Record<string, number> = {
black: 0,
red: 1,
green: 2,
yellow: 3,
blue: 4,
magenta: 5,
cyan: 6,
white: 7,
brightblack: 8,
brightred: 9,
brightgreen: 10,
brightyellow: 11,
brightblue: 12,
brightmagenta: 13,
brightcyan: 14,
brightwhite: 15,
}

export function parseColor(color: ColorInput): RGBA {
if (typeof color === "string") {
const lowerColor = color.toLowerCase()
Expand All @@ -195,7 +223,12 @@ export function parseColor(color: ColorInput): RGBA {
}

if (CSS_COLOR_NAMES[lowerColor]) {
return hexToRgb(CSS_COLOR_NAMES[lowerColor])
const rgba = hexToRgb(CSS_COLOR_NAMES[lowerColor])
const ansi16 = ANSI_16_INDEX[lowerColor]
if (ansi16 !== undefined) {
rgba.ansi16Index = ansi16
}
return rgba
}

return hexToRgb(color)
Expand Down
83 changes: 83 additions & 0 deletions packages/core/src/renderables/Box.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,89 @@ describe("BoxRenderable - borderStyle validation", () => {
})
})

describe("BoxRenderable - named ANSI color index preservation", () => {
test("backgroundColor named color preserves ansi16Index on stored RGBA", () => {
const box = new BoxRenderable(testRenderer, {
id: "ansi-bg-box",
width: 10,
height: 5,
backgroundColor: "red",
})

expect(box.backgroundColor.ansi16Index).toBe(1)
})

test("borderColor named color preserves ansi16Index on stored RGBA", () => {
const box = new BoxRenderable(testRenderer, {
id: "ansi-border-box",
width: 10,
height: 5,
borderColor: "blue",
})

expect(box.borderColor.ansi16Index).toBe(4)
})

test("bright named color preserves ansi16Index on stored RGBA", () => {
const box = new BoxRenderable(testRenderer, {
id: "ansi-bright-box",
width: 10,
height: 5,
backgroundColor: "brightcyan",
})

expect(box.backgroundColor.ansi16Index).toBe(14)
})

test("mixed-case named color normalises and preserves ansi16Index", () => {
const box = new BoxRenderable(testRenderer, {
id: "ansi-case-box",
width: 10,
height: 5,
backgroundColor: "brightCyan",
})

expect(box.backgroundColor.ansi16Index).toBe(14)
})

test("hex backgroundColor does not set ansi16Index", () => {
const box = new BoxRenderable(testRenderer, {
id: "hex-bg-box",
width: 10,
height: 5,
backgroundColor: "#FF0000",
})

expect(box.backgroundColor.ansi16Index).toBeUndefined()
})

test("non-ANSI named color (gray) does not set ansi16Index", () => {
const box = new BoxRenderable(testRenderer, {
id: "gray-bg-box",
width: 10,
height: 5,
backgroundColor: "gray",
})

expect(box.backgroundColor.ansi16Index).toBeUndefined()
})

test("backgroundColor setter updates ansi16Index", () => {
const box = new BoxRenderable(testRenderer, {
id: "setter-box",
width: 10,
height: 5,
backgroundColor: "#FF0000",
})

expect(box.backgroundColor.ansi16Index).toBeUndefined()

box.backgroundColor = "green"

expect(box.backgroundColor.ansi16Index).toBe(2)
})
})

describe("BoxRenderable - border titles (top and bottom)", () => {
test("renders top and bottom titles on their respective borders", async () => {
const box = new BoxRenderable(testRenderer, {
Expand Down
22 changes: 14 additions & 8 deletions packages/core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1265,16 +1265,22 @@ export class CliRenderer extends EventEmitter implements RenderContext {

let clear = ""
if (space > 0) {
const backgroundColor = this.backgroundColor.toInts()
const newlines = " ".repeat(this.width) + "\n".repeat(space)
// Check if background is transparent (alpha = 0)
if (backgroundColor[3] === 0) {
clear = newlines
const { ansi16Index } = this.backgroundColor
if (ansi16Index !== undefined) {
// Named ANSI 16-color: emit palette code to respect the user's terminal theme
clear = ANSI.setAnsi16Background(ansi16Index) + newlines + ANSI.resetBackground
} else {
clear =
ANSI.setRgbBackground(backgroundColor[0], backgroundColor[1], backgroundColor[2]) +
newlines +
ANSI.resetBackground
const backgroundColor = this.backgroundColor.toInts()
// Check if background is transparent (alpha = 0)
if (backgroundColor[3] === 0) {
clear = newlines
} else {
clear =
ANSI.setRgbBackground(backgroundColor[0], backgroundColor[1], backgroundColor[2]) +
newlines +
ANSI.resetBackground
}
}
}

Expand Down