fix(runtime): nest Text ANSI styles per-style in Ink's order (Ink parity, G68) (#65)
Ink's Text.tsx transform applies each enabled style as a SEPARATE nested chalk wrap, in the exact order dim -> color -> backgroundColor -> bold -> italic -> underline -> strikethrough -> inverse. vue-tui's applyChalk built ONE chained ChalkInstance (color -> bg -> dim -> bold -> ...) and invoked it once, producing a different, non-Ink byte sequence for any multi-style Text: e.g. color+bold emitted [31m[1mX[22m[39m vs Ink's [1m[31mX[39m[22m, and dim+bold dropped the bold re-open after dim's SGR-22 reset. Rewrite applyChalk to mirror Ink: apply each style as its own nested chalk(...) call in Ink's order, reusing the existing color/background resolution. Chalk level handling (FORCE_COLOR / level 0 -> no codes) is preserved and ANSI codes remain zero-width, so styled-text measurement is unchanged. Multi-style Text now produces byte-identical ANSI to Ink. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
## Sweep history
|
## Sweep history
|
||||||
|
|
||||||
| sweep | Ink SHA | candidates → confirmed | status |
|
| sweep | Ink SHA | candidates → confirmed | status |
|
||||||
| -------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
| --------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
|
||||||
| sweep-1 (2026-05-29) | `40b3a75` | 16 verified → 14 confirmed (2 refuted) | recorded |
|
| sweep-1 (2026-05-29) | `40b3a75` | 16 verified → 14 confirmed (2 refuted) | recorded |
|
||||||
| sweep-2 (2026-05-30) | `40b3a75` | 8 confirmed (re-audit after 16 fixes merged) → 6 new gaps (G18-G23) + 1 candidate (G24) | recorded |
|
| sweep-2 (2026-05-30) | `40b3a75` | 8 confirmed (re-audit after 16 fixes merged) → 6 new gaps (G18-G23) + 1 candidate (G24) | recorded |
|
||||||
| sweep-3 (2026-05-30) | `40b3a75` | 8 confirmed (re-audit, 22 fixes merged) → 7 new gaps (G25-G31, all LOW) + 1 refuted | recorded |
|
| sweep-3 (2026-05-30) | `40b3a75` | 8 confirmed (re-audit, 22 fixes merged) → 7 new gaps (G25-G31, all LOW) + 1 refuted | recorded |
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
| sweep-7 (2026-05-30) | `40b3a75` | re-audit (G44/G45/G46 merged) → 6 confirmed: **1 MEDIUM (G52 Transform index shift from comment-node siblings) + 5 LOW (G53-G57)** | recorded |
|
| sweep-7 (2026-05-30) | `40b3a75` | re-audit (G44/G45/G46 merged) → 6 confirmed: **1 MEDIUM (G52 Transform index shift from comment-node siblings) + 5 LOW (G53-G57)** | recorded |
|
||||||
| sweep-8 (2026-05-30) | `40b3a75` | re-audit (G52 merged) → 5 confirmed (3 refuted): **2 MEDIUM (G58 standalone-Transform bare-text dropped, G59 SR frame routes through interactive clearTerminal/static path) + 3 LOW (G60-G62)** | recorded |
|
| sweep-8 (2026-05-30) | `40b3a75` | re-audit (G52 merged) → 5 confirmed (3 refuted): **2 MEDIUM (G58 standalone-Transform bare-text dropped, G59 SR frame routes through interactive clearTerminal/static path) + 3 LOW (G60-G62)** | recorded |
|
||||||
| sweep-9 (2026-05-30) | `40b3a75` | re-audit (G58/G59 merged) → 8 cand → 4 confirmed (4 refuted): **2 MEDIUM (G63 transform-then-clip order, G64 Static forces terminal width vs Ink content-size) + 2 LOW (G65-G66)** | recorded |
|
| sweep-9 (2026-05-30) | `40b3a75` | re-audit (G58/G59 merged) → 8 cand → 4 confirmed (4 refuted): **2 MEDIUM (G63 transform-then-clip order, G64 Static forces terminal width vs Ink content-size) + 2 LOW (G65-G66)** | recorded |
|
||||||
|
| sweep-10 (2026-05-30) | `40b3a75` | re-audit (G63/G64 merged) → 8 cand → 7 confirmed (1 refuted): **3 actionable MEDIUM (G68 Text ANSI nesting order, G69 Static throttled vs immediate, G70 non-interactive SR hard-wrap) + G67 (refuted on codex review — unavoidable Vue-vs-React semantic) + 3 LOW (G71-G73)** | recorded |
|
||||||
|
|
||||||
Refuted (NOT gaps): ~~`exit()` second-wins — vue-tui is already guarded~~ **REVERSED by sweep-4: it IS last-wins vs Ink first-wins → now tracked as G33**; kitty key-release printable-text suppression — Ink behaves the same.
|
Refuted (NOT gaps): ~~`exit()` second-wins — vue-tui is already guarded~~ **REVERSED by sweep-4: it IS last-wins vs Ink first-wins → now tracked as G33**; kitty key-release printable-text suppression — Ink behaves the same.
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ Refuted (NOT gaps): ~~`exit()` second-wins — vue-tui is already guarded~~ **RE
|
|||||||
Non-obvious calls made while fixing gaps, recorded for review in the final report.
|
Non-obvious calls made while fixing gaps, recorded for review in the final report.
|
||||||
|
|
||||||
- **2026-05-29 — Conflict policy (set by maintainer):** when an audit gap conflicts with an existing vue-tui test that deliberately asserts non-Ink behavior, align to Ink and rewrite the conflicting test — and record the decision here so it can be reviewed against expectations later.
|
- **2026-05-29 — Conflict policy (set by maintainer):** when an audit gap conflicts with an existing vue-tui test that deliberately asserts non-Ink behavior, align to Ink and rewrite the conflicting test — and record the decision here so it can be reviewed against expectations later.
|
||||||
|
- **2026-05-30 — G68 (Text ANSI nesting order):** Rewrote `applyChalk` (`packages/runtime/src/paint/text-style.ts`) to mirror Ink's `Text.tsx` transform — each enabled style applied as its OWN nested chalk call in Ink's exact order (dim→color→bg→bold→italic→underline→strikethrough→inverse) instead of one chained `ChalkInstance`. Byte sequences verified against /tmp/ink-40b3a75 @ chalk level 1 (color+bold `[1m[31mX[39m[22m`, dim+bold `[1m[2mX[22m[1m[22m`, color+bg `[44m[31mX[39m[49m`). Conflict-policy rewrites (align to Ink): the `text-style.test.ts` "multiple modifiers chain" case asserted the old chained order (`chalk.bold.underline`) → rewritten to nested `chalk.underline(chalk.bold("x"))`; and the `borders.test.tsx` "borderDimColor does not dim styled child Text touching left edge" inline snapshot asserted old chained bytes `[34m[1m…[22m[39m` for `bold color="blue"` → updated to Ink's nested `[1m[34m…[39m[22m` (verified vs Ink). Single-style Text, level-0 (no codes), and styled-text width (ANSI is zero-width) all unchanged.
|
||||||
|
- **2026-05-30 — G67 REFUTED (FLAG for maintainer):** sweep-10 flagged that vue-tui resets `flexDirection`/`flexWrap` to row/nowrap on dynamic prop removal while Ink's host-level `applyFlexStyles` keeps the stale value. On codex review this was REFUTED: Ink's `<Box>` injects `flexDirection:'row'`/`flexWrap:'nowrap'` defaults BEFORE the style spread (Box.tsx), so an OMITTED prop resolves to row/nowrap (the common case) — which vue-tui's G19 reset already matches. Ink only keeps the stale value for an explicit `flexDirection={undefined}` (rare). **Vue cannot distinguish an omitted prop from an explicit `undefined`** (both collapse to the prop default), so vue-tui must pick ONE behavior: it keeps G19's reset-to-default, which matches Ink's COMMON omitted case and is Vue-idiomatic. The residual divergence (explicit `={undefined}` → vue resets, Ink keeps stale) is an UNAVOIDABLE Vue-vs-React semantic, left as-is. Decision: do NOT "fix" G67; keep G19. Surfaced here for maintainer review.
|
||||||
- **2026-05-29 — Sequencing:** the P1 gaps G01 (Static unmount), G02 (useAnimation throttle coalescing), G03 (screen-reader render path) are architecturally invasive — they hinge on timing between Vue's reactivity and the scheduler's commit, where a mistake silently drops output. Deferred until the end-to-end pipeline (PR → codex → CI → auto-merge) is validated on a low-risk fix. Started with G04.
|
- **2026-05-29 — Sequencing:** the P1 gaps G01 (Static unmount), G02 (useAnimation throttle coalescing), G03 (screen-reader render path) are architecturally invasive — they hinge on timing between Vue's reactivity and the scheduler's commit, where a mistake silently drops output. Deferred until the end-to-end pipeline (PR → codex → CI → auto-merge) is validated on a low-risk fix. Started with G04.
|
||||||
- **2026-05-29 — G04 (border background):** maintainer confirmed Ink behavior — border glyphs carry NO background unless `borderBackgroundColor` (or a per-edge variant) is set. Removed the `?? bgColor` fallback in `paint.ts` `colorizeEdge`. Tests rewritten to match Ink:
|
- **2026-05-29 — G04 (border background):** maintainer confirmed Ink behavior — border glyphs carry NO background unless `borderBackgroundColor` (or a per-edge variant) is set. Removed the `?? bgColor` fallback in `paint.ts` `colorizeEdge`. Tests rewritten to match Ink:
|
||||||
- _"wrapped text preserves backgroundColor on every line"_ → _"…on every content line"_: now asserts inner content rows carry the bg while the pure-border first/last rows do not (box height 4→5 so the wrapped text fits without overflowing onto the bottom border).
|
- _"wrapped text preserves backgroundColor on every line"_ → _"…on every content line"_: now asserts inner content rows carry the bg while the pure-border first/last rows do not (box height 4→5 so the wrapped text fits without overflowing onto the bottom border).
|
||||||
@@ -45,7 +48,7 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor
|
|||||||
`status` ∈ `todo · in-progress · pr-open · merged · blocked · refuted · candidate`. Priority: correctness/behavior first, omissions next.
|
`status` ∈ `todo · in-progress · pr-open · merged · blocked · refuted · candidate`. Priority: correctness/behavior first, omissions next.
|
||||||
|
|
||||||
| id | area | summary | priority | status | branch | PR |
|
| id | area | summary | priority | status | branch | PR |
|
||||||
| --- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | ---------------------------------------------- | --- |
|
| --- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | ---------------------------------------------- | --- |
|
||||||
| G01 | static-newline-spacer | Static keeps every already-written item permanently mounted instead of unmounting it | P1 | merged | `fix/parity-static-unmount` | #34 |
|
| G01 | static-newline-spacer | Static keeps every already-written item permanently mounted instead of unmounting it | P1 | merged | `fix/parity-static-unmount` | #34 |
|
||||||
| G02 | app-exit-instances-animation-sr | useAnimation does not coalesce ticks within the render-throttle window — delta does not 'account for throttled renders' | P1 | merged | `fix/parity-useanimation-throttle` | #35 |
|
| G02 | app-exit-instances-animation-sr | useAnimation does not coalesce ticks within the render-throttle window — delta does not 'account for throttled renders' | P1 | merged | `fix/parity-useanimation-throttle` | #35 |
|
||||||
| G03 | render-lifecycle-reconciler | Live screen-reader render path is missing; commit() always paints the visual grid | P1 | merged | `fix/parity-sr-render` | #36 |
|
| G03 | render-lifecycle-reconciler | Live screen-reader render path is missing; commit() always paints the visual grid | P1 | merged | `fix/parity-sr-render` | #36 |
|
||||||
@@ -108,10 +111,17 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor
|
|||||||
| G60 | box-layout-border | String-typed dimension/position values (bare numeric string e.g. width="50") treated as POINT not PERCENT; Ink applies all string dims as percent — sweep-8 LOW | P3 | todo | — | — |
|
| G60 | box-layout-border | String-typed dimension/position values (bare numeric string e.g. width="50") treated as POINT not PERCENT; Ink applies all string dims as percent — sweep-8 LOW | P3 | todo | — | — |
|
||||||
| G61 | stdout-stderr-stdin-size-cursor | Bracketed-paste disable write during stdin dispose() lacks Ink's destroyed/writableEnded (canWriteToStdout) guard — sweep-8 LOW | P3 | todo | — | — |
|
| G61 | stdout-stderr-stdin-size-cursor | Bracketed-paste disable write during stdin dispose() lacks Ink's destroyed/writableEnded (canWriteToStdout) guard — sweep-8 LOW | P3 | todo | — | — |
|
||||||
| G62 | render-lifecycle-reconciler | resolveExit()/teardown() writable-stream checks omit the writableLength fallback + stdout.writable flag that Ink's getWritableStreamState uses — sweep-8 LOW | P3 | todo | — | — |
|
| G62 | render-lifecycle-reconciler | resolveExit()/teardown() writable-stream checks omit the writableLength fallback + stdout.writable flag that Ink's getWritableStreamState uses — sweep-8 LOW | P3 | todo | — | — |
|
||||||
| G63 | text-wrap-transform | Transform applied to the FULL line THEN horizontally clipped; Ink clips THEN transforms (output.ts), so a width-sensitive transform (gradient / OSC-8 hyperlink) inside an overflowX:hidden box gets the wrong char span (MEDIUM, sweep-9) | P1 | pr-open | `fix/parity-clip-then-transform` | #63 |
|
| G63 | text-wrap-transform | Transform applied to the FULL line THEN horizontally clipped; Ink clips THEN transforms (output.ts), so a width-sensitive transform (gradient / OSC-8 hyperlink) inside an overflowX:hidden box gets the wrong char span (MEDIUM, sweep-9) | P1 | merged | `fix/parity-clip-then-transform` | #63 |
|
||||||
| G64 | static-newline-spacer | `<Static>` isolated paint forces terminal width; Ink content-sizes the static box (position:absolute, auto width) so flex-fill children (Spacer/flexGrow/justify/align/percent) collapse to content — refines G44's over-correction (MEDIUM, sweep-9) | P1 | pr-open | `fix/parity-static-content-size` | #64 |
|
| G64 | static-newline-spacer | `<Static>` isolated paint forces terminal width; Ink content-sizes the static box (position:absolute, auto width) so flex-fill children (Spacer/flexGrow/justify/align/percent) collapse to content — refines G44's over-correction (MEDIUM, sweep-9) | P1 | merged | `fix/parity-static-content-size` | #64 |
|
||||||
| G65 | app-exit-instances-animation-sr | SR unchanged-frame skip compares WRAPPED output, not Ink's UNWRAPPED linearization — diverges on resize with identical content — sweep-9 LOW | P3 | todo | — | — |
|
| G65 | app-exit-instances-animation-sr | SR unchanged-frame skip compares WRAPPED output, not Ink's UNWRAPPED linearization — diverges on resize with identical content — sweep-9 LOW | P3 | todo | — | — |
|
||||||
| G66 | app-exit-instances-animation-sr | app.waitUntilRenderFlush() lacks Ink's unmount/unmounting short-circuit to awaitExit() — sweep-9 LOW | P3 | todo | — | — |
|
| G66 | app-exit-instances-animation-sr | app.waitUntilRenderFlush() lacks Ink's unmount/unmounting short-circuit to awaitExit() — sweep-9 LOW | P3 | todo | — | — |
|
||||||
|
| G67 | box-layout-border | ~~flexDirection/flexWrap reset on dynamic removal vs Ink keep-stale~~ **REFUTED (codex, sweep-10):** Ink's `<Box>` injects row/nowrap defaults BEFORE the style spread, so OMITTING the prop → row (common case, which vue-tui's G19 reset already matches); Ink keeps stale ONLY for explicit `flexDirection={undefined}` (rare), which Vue CANNOT distinguish from omission. Keeping stale would break the common case. UNAVOIDABLE Vue-vs-React semantic — KEEP G19's reset-to-default. **FLAG for maintainer.** | P1 | refuted | — | — |
|
||||||
|
| G68 | text-wrap-transform | Text style ANSI nesting/order: vue-tui builds ONE chained chalk (color→bg→dim→bold…); Ink applies each style as a SEPARATE nested wrap in a different order (dim→color→bg→bold…) — bytes diverge for multi-style Text (MEDIUM, sweep-10) | P1 | pr-open | `fix/parity-text-ansi-order` | #65 |
|
||||||
|
| G69 | render-lifecycle-reconciler | `<Static>` output is throttled, not immediate; Ink has an isStaticDirty/onImmediateRender escape hatch that paints freshly-appended Static children synchronously at commit before they can be sliced out (MEDIUM, sweep-10) | P1 | todo | — | — |
|
||||||
|
| G70 | app-exit-instances-animation-sr | Non-interactive (and debug) screen-reader output is hard-wrapped at terminal width; Ink keeps it unwrapped (wrap only in the interactive SR branch) — diverges from vue-tui's own renderToString (MEDIUM, sweep-10) | P1 | todo | — | — |
|
||||||
|
| G71 | focus | useFocusManager().activeId is null (not undefined) when nothing is focused — sweep-10 LOW | P3 | todo | — | — |
|
||||||
|
| G72 | render-lifecycle-reconciler | Layout computation + layout-listener (measureElement/useBoxMetrics) callbacks are throttled, not immediate — sweep-10 LOW | P3 | todo | — | — |
|
||||||
|
| G73 | render-lifecycle-reconciler | Show-cursor escape (\x1b[?25h) written twice on normal interactive unmount — sweep-10 LOW | P3 | todo | — | — |
|
||||||
|
|
||||||
## Gap details
|
## Gap details
|
||||||
|
|
||||||
@@ -390,9 +400,10 @@ _area:_ `render-lifecycle-reconciler` · _kind:_ behavior · _severity:_ HIGH ·
|
|||||||
|
|
||||||
_area:_ `box-layout-border` · _kind:_ behavior · _severity:_ medium · _priority:_ P2 · _(sweep-2)_
|
_area:_ `box-layout-border` · _kind:_ behavior · _severity:_ medium · _priority:_ P2 · _(sweep-2)_
|
||||||
|
|
||||||
- **Ink:** reconciler diff emits `key: undefined` on removal; styles.ts applies the yoga default (margin/padding/minWidth/flexGrow→0, flexShrink→1, flexBasis→auto, flexDirection→ROW, position→RELATIVE, align/justify→defaults).
|
- **Ink:** reconciler diff emits `key: undefined` on removal; styles.ts applies the yoga default (margin/padding/minWidth/flexGrow→0, flexShrink→1, flexBasis→auto, position→RELATIVE, align/justify→defaults).
|
||||||
- **vue-tui:** host/yoga.ts:312 early-returns on `value===undefined` except RESETTABLE_PROPS (width/height/max*/aspectRatio/alignContent/top/right/bottom/left). All margin/padding/minWidth/minHeight/gap/flex*/justify/align/flexDirection/flexWrap/position keep their STALE value when the prop is removed across renders.
|
- **vue-tui:** host/yoga.ts:312 early-returns on `value===undefined` except RESETTABLE_PROPS (width/height/max*/aspectRatio/alignContent/top/right/bottom/left). All margin/padding/minWidth/minHeight/gap/flex*/justify/align/flexDirection/flexWrap/position keep their STALE value when the prop is removed across renders.
|
||||||
- **Fix sketch:** when value===undefined for these props, apply the documented yoga default instead of early-returning (extend RESETTABLE_PROPS + a per-prop default map). Tests: remove marginTop/paddingTop/minWidth/gap/flexGrow/justifyContent/position across renders.
|
- **Fix sketch:** when value===undefined for these props, apply the documented yoga default instead of early-returning (extend RESETTABLE_PROPS + a per-prop default map). Tests: remove marginTop/paddingTop/minWidth/gap/flexGrow/justifyContent/position across renders.
|
||||||
|
- **CORRECTION (G67, sweep-10):** `flexDirection`/`flexWrap` are SPECIAL — Ink's `applyFlexStyles` has NO undefined branch for them, so at the host level a `key: undefined` keeps the STALE value (unlike the other flex props which reset). The G19 fix listed `flexDirection→ROW`/flexWrap→NO_WRAP as resets — technically the wrong host-level mechanism, BUT the net behavior still matches Ink for the COMMON case: Ink's `<Box>` injects `flexDirection:'row'`/`flexWrap:'nowrap'` defaults before the style spread, so an OMITTED prop yields `row`/`nowrap` (which vue-tui's reset also yields). Ink only keeps-stale for an explicit `flexDirection={undefined}` (rare), which Vue CANNOT distinguish from omission. So G67 ("make them keep-stale") was REFUTED — it would break the common omitted-prop case. vue-tui keeps G19's reset-to-default for flexDirection/flexWrap; the explicit-`={undefined}` divergence is an unavoidable Vue-vs-React semantic. See G67 row + Decisions log. **FLAG for maintainer.**
|
||||||
|
|
||||||
### G20 — writeToStdout/writeToStderr lack an isUnmounted guard
|
### G20 — writeToStdout/writeToStderr lack an isUnmounted guard
|
||||||
|
|
||||||
|
|||||||
@@ -1340,7 +1340,7 @@ test("borderDimColor does not dim styled child Text touching left edge", async (
|
|||||||
);
|
);
|
||||||
expect(lastFrame()).toMatchInlineSnapshot(`
|
expect(lastFrame()).toMatchInlineSnapshot(`
|
||||||
"[2m╭───────────╮[22m
|
"[2m╭───────────╮[22m
|
||||||
[2m│[22m[34m[1mstyled text[22m[39m[2m│[22m
|
[2m│[22m[1m[34mstyled text[39m[22m[2m│[22m
|
||||||
[2m╰───────────╯[22m"
|
[2m╰───────────╯[22m"
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,11 +50,109 @@ test("ansi256 background color applies chalk.bgAnsi256", () => {
|
|||||||
expect(applyChalk("x", { backgroundColor: "ansi256(194)" })).toBe(chalk.bgAnsi256(194)("x"));
|
expect(applyChalk("x", { backgroundColor: "ansi256(194)" })).toBe(chalk.bgAnsi256(194)("x"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("multiple modifiers chain", () => {
|
// G68 follow-up: ANSI-form color strings must be validated exactly like Ink's
|
||||||
|
// colorize.ts (commit 40b3a75). Confirmed against /tmp/ink-40b3a75 by running
|
||||||
|
// its compiled colorize at chalk.level 1:
|
||||||
|
// colorize("X","ansi256(foo)", *) -> "X" (regex capture fails)
|
||||||
|
// colorize("X","ansi(194)", *) -> "X" (ansi(...) is NOT a form)
|
||||||
|
// colorize("X","ansi256(194)", fg) -> ESC[38;5;194m X ESC[39m
|
||||||
|
// Before this fix applyColor emitted a NaN SGR (ESC[38;5;NaNm) for ansi256(foo)
|
||||||
|
// and wrongly colored ansi(194).
|
||||||
|
test("unparseable ansi256(foo) emits no codes (Ink validation)", () => {
|
||||||
const prev = chalk.level;
|
const prev = chalk.level;
|
||||||
chalk.level = 1;
|
chalk.level = 1;
|
||||||
try {
|
try {
|
||||||
expect(applyChalk("x", { bold: true, underline: true })).toBe(chalk.bold.underline("x"));
|
expect(applyChalk("X", { color: "ansi256(foo)" })).toBe("X");
|
||||||
|
expect(applyChalk("X", { backgroundColor: "ansi256(foo)" })).toBe("X");
|
||||||
|
} finally {
|
||||||
|
chalk.level = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ansi(194) is not a supported Ink form, emits no codes", () => {
|
||||||
|
const prev = chalk.level;
|
||||||
|
chalk.level = 1;
|
||||||
|
try {
|
||||||
|
expect(applyChalk("X", { color: "ansi(194)" })).toBe("X");
|
||||||
|
expect(applyChalk("X", { backgroundColor: "ansi(194)" })).toBe("X");
|
||||||
|
} finally {
|
||||||
|
chalk.level = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("valid ansi256(194) still colors after hardening", () => {
|
||||||
|
const prev = chalk.level;
|
||||||
|
chalk.level = 1;
|
||||||
|
try {
|
||||||
|
expect(applyChalk("X", { color: "ansi256(194)" })).toBe(chalk.ansi256(194)("X"));
|
||||||
|
expect(applyChalk("X", { backgroundColor: "ansi256(194)" })).toBe(chalk.bgAnsi256(194)("X"));
|
||||||
|
} finally {
|
||||||
|
chalk.level = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple modifiers chain", () => {
|
||||||
|
const prev = chalk.level;
|
||||||
|
chalk.level = 1;
|
||||||
|
try {
|
||||||
|
// Ink nests each style as its own chalk wrap in order
|
||||||
|
// dim,color,bg,bold,italic,underline,strikethrough,inverse.
|
||||||
|
// bold then underline => underline(bold(x)).
|
||||||
|
expect(applyChalk("x", { bold: true, underline: true })).toBe(chalk.underline(chalk.bold("x")));
|
||||||
|
} finally {
|
||||||
|
chalk.level = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// G68: Ink (Text.tsx transform) applies each enabled style as its OWN nested
|
||||||
|
// chalk call in the exact order dim,color,bg,bold,italic,underline,
|
||||||
|
// strikethrough,inverse. Byte sequences below confirmed against the Ink
|
||||||
|
// reference at /tmp/ink-40b3a75 (commit 40b3a75) by running chalk@level 1.
|
||||||
|
test("color+bold nests bold outside color (Ink order)", () => {
|
||||||
|
const prev = chalk.level;
|
||||||
|
chalk.level = 1;
|
||||||
|
try {
|
||||||
|
// ESC[1m ESC[31m X ESC[39m ESC[22m
|
||||||
|
expect(applyChalk("X", { color: "red", bold: true })).toBe(chalk.bold(chalk.red("X")));
|
||||||
|
expect(applyChalk("X", { color: "red", bold: true })).toBe("[1m[31mX[39m[22m");
|
||||||
|
} finally {
|
||||||
|
chalk.level = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dim+bold re-opens bold after dim's SGR-22 reset (Ink order)", () => {
|
||||||
|
const prev = chalk.level;
|
||||||
|
chalk.level = 1;
|
||||||
|
try {
|
||||||
|
// ESC[1m ESC[2m X ESC[22m ESC[1m ESC[22m
|
||||||
|
expect(applyChalk("X", { dimColor: true, bold: true })).toBe(chalk.bold(chalk.dim("X")));
|
||||||
|
expect(applyChalk("X", { dimColor: true, bold: true })).toBe("[1m[2mX[22m[1m[22m");
|
||||||
|
} finally {
|
||||||
|
chalk.level = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("color+backgroundColor nests bg outside color (Ink order)", () => {
|
||||||
|
const prev = chalk.level;
|
||||||
|
chalk.level = 1;
|
||||||
|
try {
|
||||||
|
// ESC[44m ESC[31m X ESC[39m ESC[49m
|
||||||
|
expect(applyChalk("X", { color: "red", backgroundColor: "blue" })).toBe(
|
||||||
|
chalk.bgBlue(chalk.red("X")),
|
||||||
|
);
|
||||||
|
expect(applyChalk("X", { color: "red", backgroundColor: "blue" })).toBe(
|
||||||
|
"[44m[31mX[39m[49m",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
chalk.level = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("level 0 emits no ANSI codes regardless of styles", () => {
|
||||||
|
const prev = chalk.level;
|
||||||
|
chalk.level = 0;
|
||||||
|
try {
|
||||||
|
expect(applyChalk("X", { color: "red", bold: true, backgroundColor: "blue" })).toBe("X");
|
||||||
} finally {
|
} finally {
|
||||||
chalk.level = prev;
|
chalk.level = prev;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import chalk, { type ChalkInstance } from "chalk";
|
import chalk, { type ChalkInstance } from "chalk";
|
||||||
import type { TextProps } from "../host/nodes.ts";
|
import type { TextProps } from "../host/nodes.ts";
|
||||||
|
|
||||||
|
// Mirror Ink's colorize.ts (commit 40b3a75) EXACTLY for the accepted color
|
||||||
|
// forms and its "no match -> bare text (no codes)" fallback. The regexes below
|
||||||
|
// match Ink's `ansiRegex`/`rgbRegex` byte-for-byte so an unparseable or
|
||||||
|
// unsupported color string produces no SGR codes (returning the chalk instance
|
||||||
|
// unchanged) instead of emitting a NaN SGR. In particular Ink supports only
|
||||||
|
// `ansi256(N)` (validated by a numeric capture) — `ansi(...)` is NOT a form and
|
||||||
|
// must fall through to bare text.
|
||||||
|
const rgbRegex = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/;
|
||||||
|
const ansi256Regex = /^ansi256\(\s?(\d+)\s?\)$/;
|
||||||
|
|
||||||
export function applyColor(
|
export function applyColor(
|
||||||
c: ChalkInstance,
|
c: ChalkInstance,
|
||||||
color: string | [number, number, number],
|
color: string | [number, number, number],
|
||||||
@@ -10,25 +20,25 @@ export function applyColor(
|
|||||||
return bg ? c.bgRgb(color[0], color[1], color[2]) : c.rgb(color[0], color[1], color[2]);
|
return bg ? c.bgRgb(color[0], color[1], color[2]) : c.rgb(color[0], color[1], color[2]);
|
||||||
}
|
}
|
||||||
if (typeof color !== "string") return c;
|
if (typeof color !== "string") return c;
|
||||||
|
// Named chalk color (validated by presence of the method, like Ink's
|
||||||
|
// `color in chalk`): apply when known, otherwise fall through to bare text.
|
||||||
|
const key = bg ? bgKey(color) : color;
|
||||||
|
const named = (c as never as Record<string, ChalkInstance>)[key];
|
||||||
|
if (typeof named === "function") return named;
|
||||||
if (color.startsWith("#")) return bg ? c.bgHex(color) : c.hex(color);
|
if (color.startsWith("#")) return bg ? c.bgHex(color) : c.hex(color);
|
||||||
if (color.startsWith("rgb(")) {
|
if (color.startsWith("ansi256")) {
|
||||||
const m = color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/);
|
const m = ansi256Regex.exec(color);
|
||||||
if (m) {
|
if (!m) return c;
|
||||||
const [r, g, b] = [+m[1]!, +m[2]!, +m[3]!];
|
const n = Number(m[1]);
|
||||||
|
return bg ? c.bgAnsi256(n) : c.ansi256(n);
|
||||||
|
}
|
||||||
|
if (color.startsWith("rgb")) {
|
||||||
|
const m = rgbRegex.exec(color);
|
||||||
|
if (!m) return c;
|
||||||
|
const [r, g, b] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||||
return bg ? c.bgRgb(r, g, b) : c.rgb(r, g, b);
|
return bg ? c.bgRgb(r, g, b) : c.rgb(r, g, b);
|
||||||
}
|
}
|
||||||
}
|
return c;
|
||||||
if (color.startsWith("ansi(")) {
|
|
||||||
const n = +color.slice(5, -1);
|
|
||||||
return bg ? c.bgAnsi256(n) : c.ansi256(n);
|
|
||||||
}
|
|
||||||
if (color.startsWith("ansi256(")) {
|
|
||||||
const n = +color.slice(8, -1);
|
|
||||||
return bg ? c.bgAnsi256(n) : c.ansi256(n);
|
|
||||||
}
|
|
||||||
const key = bg ? bgKey(color) : color;
|
|
||||||
const fn = (c as never as Record<string, ChalkInstance>)[key];
|
|
||||||
return fn ?? c;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bgKey(name: string): string {
|
function bgKey(name: string): string {
|
||||||
@@ -36,14 +46,21 @@ function bgKey(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function applyChalk(text: string, props: TextProps): string {
|
export function applyChalk(text: string, props: TextProps): string {
|
||||||
let style: ChalkInstance = chalk;
|
// Mirror Ink's Text.tsx `transform` (commit 40b3a75): apply each enabled
|
||||||
if (props.color) style = applyColor(style, props.color as never, false);
|
// style as its OWN nested chalk call, in the exact order
|
||||||
if (props.backgroundColor) style = applyColor(style, props.backgroundColor as never, true);
|
// dim -> color -> backgroundColor -> bold -> italic -> underline ->
|
||||||
if (props.dimColor) style = style.dim;
|
// strikethrough -> inverse. This produces individually-balanced open/close
|
||||||
if (props.bold) style = style.bold;
|
// pairs (e.g. dim+bold re-opens bold after dim's SGR-22 reset), which is
|
||||||
if (props.italic) style = style.italic;
|
// byte-identical to Ink. A single chained ChalkInstance would emit a
|
||||||
if (props.underline) style = style.underline;
|
// different, non-Ink byte sequence for any multi-style Text (G68).
|
||||||
if (props.strikethrough) style = style.strikethrough;
|
let s = text;
|
||||||
if (props.inverse) style = style.inverse;
|
if (props.dimColor) s = chalk.dim(s);
|
||||||
return style(text);
|
if (props.color) s = applyColor(chalk, props.color as never, false)(s);
|
||||||
|
if (props.backgroundColor) s = applyColor(chalk, props.backgroundColor as never, true)(s);
|
||||||
|
if (props.bold) s = chalk.bold(s);
|
||||||
|
if (props.italic) s = chalk.italic(s);
|
||||||
|
if (props.underline) s = chalk.underline(s);
|
||||||
|
if (props.strikethrough) s = chalk.strikethrough(s);
|
||||||
|
if (props.inverse) s = chalk.inverse(s);
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user