@markii/ansi 0.16.0 → 0.17.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/dist/components/callout.d.ts +15 -1
  3. package/dist/components/callout.js +32 -19
  4. package/dist/components/card.d.ts +10 -0
  5. package/dist/components/card.js +21 -13
  6. package/dist/components/chart.d.ts +1 -1
  7. package/dist/components/chart.js +8 -9
  8. package/dist/components/details-string.js +1 -1
  9. package/dist/components/details.d.ts +3 -1
  10. package/dist/components/details.js +7 -6
  11. package/dist/components/divider.js +3 -5
  12. package/dist/components/progress.js +2 -3
  13. package/dist/components/rating.js +2 -2
  14. package/dist/components/row.d.ts +2 -0
  15. package/dist/components/row.js +3 -1
  16. package/dist/components/stat.js +1 -2
  17. package/dist/components/table-grid.d.ts +2 -1
  18. package/dist/components/table-grid.js +13 -10
  19. package/dist/components/table.js +1 -1
  20. package/dist/components/tabs.d.ts +3 -1
  21. package/dist/components/tabs.js +25 -7
  22. package/dist/failure-presentation.d.ts +28 -0
  23. package/dist/failure-presentation.js +41 -0
  24. package/dist/glyphs.d.ts +73 -0
  25. package/dist/glyphs.js +88 -0
  26. package/dist/index.d.ts +5 -1
  27. package/dist/index.js +4 -0
  28. package/dist/ink-string.d.ts +11 -0
  29. package/dist/ink-string.js +24 -0
  30. package/dist/interactive.d.ts +31 -6
  31. package/dist/interactive.js +155 -12
  32. package/dist/pack-loader.d.ts +64 -0
  33. package/dist/pack-loader.js +172 -0
  34. package/dist/pack-script.d.ts +44 -0
  35. package/dist/pack-script.js +135 -0
  36. package/dist/registry.d.ts +15 -0
  37. package/dist/render.d.ts +48 -1
  38. package/dist/render.js +441 -85
  39. package/dist/text-grid.d.ts +43 -7
  40. package/dist/text-grid.js +69 -18
  41. package/package.json +6 -5
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sadigaxund
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,20 @@
1
+ import type { GlyphSet } from '../glyphs.js';
1
2
  import type { ResolvedLayoutPresets } from '../layout.js';
2
3
  import { type AnsiComponent, type DirectiveAttributes } from '../registry.js';
4
+ import type { Tier1Token } from '../theme.js';
3
5
  export type CalloutType = 'info' | 'warning' | 'danger';
6
+ export declare const CALLOUT_TOKENS: Record<CalloutType, Tier1Token>;
7
+ export declare function isCalloutType(value: string): value is CalloutType;
8
+ /** `headerText` for `type`, e.g. `ℹ Info` — needed by both `resolveCalloutInnerWidth` (before there is a component instance) and `Callout` itself. */
9
+ export declare function headerTextFor(type: CalloutType, glyphs: GlyphSet): string;
10
+ /** The resolved `type` of a callout: an unknown or missing value is `info`. */
11
+ export declare function resolveCalloutType(attributes: DirectiveAttributes): CalloutType;
12
+ /**
13
+ * The callout's own OUTER (bar-including) width: what `Callout` draws at
14
+ * and what the Ink element form in `render.tsx` sizes its `Box` to.
15
+ * `title` is expected already sanitized, as `Callout` passes it.
16
+ */
17
+ export declare function resolveCalloutBoxWidth(type: CalloutType, title: string | null, layout: ResolvedLayoutPresets | undefined, outerWidth: number, glyphs: GlyphSet): number;
4
18
  /**
5
19
  * The inner budget `callout`'s body renders at, given ONLY its own
6
20
  * attributes and the outer width — never the body itself. Mirrors
@@ -11,7 +25,7 @@ export type CalloutType = 'info' | 'warning' | 'danger';
11
25
  * `resolveCardInnerWidth`'s doc comment for why that re-wrap-afterward
12
26
  * shape was a real, shipped regression.
13
27
  */
14
- export declare function resolveCalloutInnerWidth(attributes: DirectiveAttributes, layout: ResolvedLayoutPresets | undefined, outerWidth: number): number;
28
+ export declare function resolveCalloutInnerWidth(attributes: DirectiveAttributes, layout: ResolvedLayoutPresets | undefined, outerWidth: number, glyphs: GlyphSet): number;
15
29
  /**
16
30
  * `:::callout{type=info|warning|danger title="..." text=left|center|right}` —
17
31
  * a colored aside/warning/danger box. Unknown/missing `type` falls back to
@@ -2,33 +2,42 @@ import { measureWidth, padText } from '../text-grid.js';
2
2
  import { selfLayoutAlign, selfLayoutWidth } from '../layout.js';
3
3
  import { childrenText, } from '../registry.js';
4
4
  const CALLOUT_TYPES = ['info', 'warning', 'danger'];
5
- const CALLOUT_ICONS = {
6
- info: 'ℹ',
7
- warning: '▲',
8
- danger: '✕',
9
- };
10
5
  const CALLOUT_LABELS = {
11
6
  info: 'Info',
12
7
  warning: 'Warning',
13
8
  danger: 'Danger',
14
9
  };
15
- const CALLOUT_TOKENS = {
10
+ export const CALLOUT_TOKENS = {
16
11
  info: '--mk-info',
17
12
  warning: '--mk-warning',
18
13
  danger: '--mk-danger',
19
14
  };
20
- function isCalloutType(value) {
15
+ export function isCalloutType(value) {
21
16
  return CALLOUT_TYPES.includes(value);
22
17
  }
23
18
  const TEXT_ALIGNS = ['left', 'center', 'right'];
24
19
  function isTextAlign(value) {
25
20
  return TEXT_ALIGNS.includes(value);
26
21
  }
27
- /** The left bar every line of a callout carries, marking it as one colored block at a glance. */
28
- const BAR = '▌ ';
29
22
  /** `headerText` for `type`, e.g. `ℹ Info` — needed by both `resolveCalloutInnerWidth` (before there is a component instance) and `Callout` itself. */
30
- function headerTextFor(type) {
31
- return `${CALLOUT_ICONS[type]} ${CALLOUT_LABELS[type]}`;
23
+ export function headerTextFor(type, glyphs) {
24
+ return `${glyphs.calloutIcons[type]} ${CALLOUT_LABELS[type]}`;
25
+ }
26
+ /** The resolved `type` of a callout: an unknown or missing value is `info`. */
27
+ export function resolveCalloutType(attributes) {
28
+ const raw = attributes.type ?? 'info';
29
+ return isCalloutType(raw) ? raw : 'info';
30
+ }
31
+ /**
32
+ * The callout's own OUTER (bar-including) width: what `Callout` draws at
33
+ * and what the Ink element form in `render.tsx` sizes its `Box` to.
34
+ * `title` is expected already sanitized, as `Callout` passes it.
35
+ */
36
+ export function resolveCalloutBoxWidth(type, title, layout, outerWidth, glyphs) {
37
+ const barWidth = measureWidth(glyphs.calloutBar);
38
+ const naturalWidth = barWidth +
39
+ Math.max(measureWidth(headerTextFor(type, glyphs)), measureWidth(title ?? ''));
40
+ return selfLayoutWidth(layout, outerWidth, naturalWidth);
32
41
  }
33
42
  /**
34
43
  * The inner budget `callout`'s body renders at, given ONLY its own
@@ -40,14 +49,17 @@ function headerTextFor(type) {
40
49
  * `resolveCardInnerWidth`'s doc comment for why that re-wrap-afterward
41
50
  * shape was a real, shipped regression.
42
51
  */
43
- export function resolveCalloutInnerWidth(attributes, layout, outerWidth) {
52
+ export function resolveCalloutInnerWidth(attributes, layout, outerWidth, glyphs) {
53
+ // The left bar every line of a callout carries, marking it as one
54
+ // colored block at a glance.
55
+ const barWidth = measureWidth(glyphs.calloutBar);
44
56
  const rawType = attributes.type ?? 'info';
45
57
  const type = isCalloutType(rawType) ? rawType : 'info';
46
58
  const title = attributes.title ?? null;
47
- const naturalWidth = BAR.length +
48
- Math.max(measureWidth(headerTextFor(type)), measureWidth(title ?? ''));
59
+ const naturalWidth = barWidth +
60
+ Math.max(measureWidth(headerTextFor(type, glyphs)), measureWidth(title ?? ''));
49
61
  const boxWidth = selfLayoutWidth(layout, outerWidth, naturalWidth);
50
- return Math.max(1, boxWidth - BAR.length);
62
+ return Math.max(1, boxWidth - barWidth);
51
63
  }
52
64
  /**
53
65
  * `:::callout{type=info|warning|danger title="..." text=left|center|right}` —
@@ -72,13 +84,14 @@ export const Callout = ({ attributes, children, ctx }) => {
72
84
  const title = attributes.title ?? null;
73
85
  const rawTextAlign = attributes.text;
74
86
  const align = rawTextAlign && isTextAlign(rawTextAlign) ? rawTextAlign : 'left';
75
- const headerText = headerTextFor(type);
87
+ const headerText = headerTextFor(type, ctx.glyphs);
76
88
  const titleText = title ? ctx.text(title) : undefined;
77
- const naturalWidth = BAR.length +
89
+ const barWidth = measureWidth(ctx.glyphs.calloutBar);
90
+ const naturalWidth = barWidth +
78
91
  Math.max(measureWidth(headerText), measureWidth(titleText ?? ''));
79
92
  const boxWidth = selfLayoutWidth(ctx.layout, ctx.width, naturalWidth);
80
- const bar = ctx.style(BAR, token);
81
- const innerWidth = Math.max(1, boxWidth - BAR.length);
93
+ const bar = ctx.style(ctx.glyphs.calloutBar, token);
94
+ const innerWidth = Math.max(1, boxWidth - barWidth);
82
95
  const placeLine = (line) => align === 'left' ? line : padText(line, innerWidth, align);
83
96
  const headerLine = `${bar}${placeLine(ctx.style(headerText, token))}`;
84
97
  const lines = [headerLine];
@@ -1,5 +1,15 @@
1
1
  import type { ResolvedLayoutPresets } from '../layout.js';
2
2
  import { type AnsiComponent, type DirectiveAttributes } from '../registry.js';
3
+ /** Frame border + one space of interior padding on each side. Exported: `render.tsx`'s Ink-`Box`-bordered ELEMENT builder needs the same overhead to go from a box width to an inner width. */
4
+ export declare const CARD_FRAME_OVERHEAD = 2;
5
+ /**
6
+ * `card`'s own natural/self-drawn OUTER (frame-including) width, given ONLY
7
+ * its own attributes, the resolved `width=`/`align=` layout preset, and the
8
+ * outer budget — never the body. Exported for `render.tsx`'s ELEMENT-mode
9
+ * builder, which needs the outer box width itself (to size its Ink `Box`)
10
+ * rather than only the inner budget `resolveCardInnerWidth` below returns.
11
+ */
12
+ export declare function resolveCardBoxWidth(attributes: DirectiveAttributes, layout: ResolvedLayoutPresets | undefined, outerWidth: number): number;
3
13
  /**
4
14
  * The inner budget `card`'s body renders at, given ONLY its own attributes
5
15
  * and the outer width — never the body itself. This is deliberately a pure
@@ -7,8 +7,22 @@ function isTextAlign(value) {
7
7
  }
8
8
  /** `width=fit` with no `title` and an unhelpfully-shaped body falls back to this column count rather than the full available width. */
9
9
  const FIT_DEFAULT_WIDTH = 40;
10
- /** Frame border + one space of interior padding on each side. */
11
- const FRAME_OVERHEAD = 2;
10
+ /** Frame border + one space of interior padding on each side. Exported: `render.tsx`'s Ink-`Box`-bordered ELEMENT builder needs the same overhead to go from a box width to an inner width. */
11
+ export const CARD_FRAME_OVERHEAD = 2;
12
+ /**
13
+ * `card`'s own natural/self-drawn OUTER (frame-including) width, given ONLY
14
+ * its own attributes, the resolved `width=`/`align=` layout preset, and the
15
+ * outer budget — never the body. Exported for `render.tsx`'s ELEMENT-mode
16
+ * builder, which needs the outer box width itself (to size its Ink `Box`)
17
+ * rather than only the inner budget `resolveCardInnerWidth` below returns.
18
+ */
19
+ export function resolveCardBoxWidth(attributes, layout, outerWidth) {
20
+ const title = attributes.title ?? null;
21
+ const naturalWidth = title
22
+ ? measureWidth(title) + CARD_FRAME_OVERHEAD * 2
23
+ : FIT_DEFAULT_WIDTH;
24
+ return selfLayoutWidth(layout, outerWidth, naturalWidth);
25
+ }
12
26
  /**
13
27
  * The inner budget `card`'s body renders at, given ONLY its own attributes
14
28
  * and the outer width — never the body itself. This is deliberately a pure
@@ -27,12 +41,8 @@ const FRAME_OVERHEAD = 2;
27
41
  * safe to have removed: this is its eager equivalent.
28
42
  */
29
43
  export function resolveCardInnerWidth(attributes, layout, outerWidth) {
30
- const title = attributes.title ?? null;
31
- const naturalWidth = title
32
- ? measureWidth(title) + FRAME_OVERHEAD * 2
33
- : FIT_DEFAULT_WIDTH;
34
- const boxWidth = selfLayoutWidth(layout, outerWidth, naturalWidth);
35
- return Math.max(1, boxWidth - FRAME_OVERHEAD);
44
+ const boxWidth = resolveCardBoxWidth(attributes, layout, outerWidth);
45
+ return Math.max(1, boxWidth - CARD_FRAME_OVERHEAD);
36
46
  }
37
47
  /**
38
48
  * `:::card{title="..." text=left|center|right} ... :::` — a titled panel.
@@ -52,11 +62,8 @@ export const Card = ({ attributes, children, ctx }) => {
52
62
  const titleText = title ? ctx.text(title) : undefined;
53
63
  const rawTextAlign = attributes.text;
54
64
  const align = rawTextAlign && isTextAlign(rawTextAlign) ? rawTextAlign : 'left';
55
- const naturalWidth = titleText
56
- ? measureWidth(titleText) + FRAME_OVERHEAD * 2
57
- : FIT_DEFAULT_WIDTH;
58
- const boxWidth = selfLayoutWidth(ctx.layout, ctx.width, naturalWidth);
59
- const innerWidth = Math.max(1, boxWidth - FRAME_OVERHEAD);
65
+ const boxWidth = resolveCardBoxWidth({ ...attributes, title: titleText ?? null }, ctx.layout, ctx.width);
66
+ const innerWidth = Math.max(1, boxWidth - CARD_FRAME_OVERHEAD);
60
67
  const bodyText = childrenText(children);
61
68
  const body = align === 'left'
62
69
  ? bodyText
@@ -68,6 +75,7 @@ export const Card = ({ attributes, children, ctx }) => {
68
75
  style: 'solid',
69
76
  title: titleText,
70
77
  width: boxWidth,
78
+ glyphs: ctx.glyphs,
71
79
  });
72
80
  return selfLayoutAlign(framed, ctx.layout, ctx.width);
73
81
  };
@@ -4,6 +4,6 @@ import type { AnsiComponent } from '../registry.js';
4
4
  * chart. Data binding (§8): a bound `data` array of numbers (or `{value}`
5
5
  * objects) takes priority over the static `values=` attribute. Terminal
6
6
  * form: a `line` chart is a single sparkline row, a `bar` chart is one
7
- * horizontal `█` bar per point.
7
+ * horizontal bar per point.
8
8
  */
9
9
  export declare const Chart: AnsiComponent;
@@ -57,26 +57,25 @@ function resolvePoints(data, dataStatus, rawValues) {
57
57
  }
58
58
  return [];
59
59
  }
60
- /** `▁▂▃▄▅▆▇█`, the eight-step block-height ramp a line chart's sparkline row is built from. */
61
- const SPARKLINE_CHARS = '▁▂▃▄▅▆▇█';
62
60
  function formatPoint(value) {
63
61
  return Number.isFinite(value)
64
62
  ? String(Math.round(value * 100) / 100)
65
63
  : String(value);
66
64
  }
67
- function sparkline(points) {
65
+ /** One sparkline row over `ramp`, the render's eight-step `GlyphSet.sparkline`. */
66
+ function sparkline(points, ramp) {
68
67
  const min = Math.min(...points);
69
68
  const max = Math.max(...points);
70
69
  const range = max - min;
71
70
  return points
72
71
  .map((point) => {
73
72
  const normalized = range > 0 ? (point - min) / range : 0.5;
74
- const index = Math.min(SPARKLINE_CHARS.length - 1, Math.max(0, Math.round(normalized * (SPARKLINE_CHARS.length - 1))));
75
- return SPARKLINE_CHARS[index];
73
+ const index = Math.min(ramp.length - 1, Math.max(0, Math.round(normalized * (ramp.length - 1))));
74
+ return ramp[index];
76
75
  })
77
76
  .join('');
78
77
  }
79
- /** `██████` bar length for `point`, scaled against the series' largest magnitude and capped at `budget` columns. */
78
+ /** A bar's length for `point`, scaled against the series' largest magnitude and capped at `budget` columns. */
80
79
  function barLength(point, maxMagnitude, budget) {
81
80
  if (maxMagnitude <= 0)
82
81
  return 0;
@@ -87,7 +86,7 @@ function barLength(point, maxMagnitude, budget) {
87
86
  * chart. Data binding (§8): a bound `data` array of numbers (or `{value}`
88
87
  * objects) takes priority over the static `values=` attribute. Terminal
89
88
  * form: a `line` chart is a single sparkline row, a `bar` chart is one
90
- * horizontal `█` bar per point.
89
+ * horizontal bar per point.
91
90
  */
92
91
  export const Chart = ({ attributes, ctx }) => {
93
92
  const { data, dataStatus, dataFailureKind } = ctx;
@@ -108,14 +107,14 @@ export const Chart = ({ attributes, ctx }) => {
108
107
  if (kind === 'line') {
109
108
  const min = ctx.dim(formatPoint(Math.min(...points)));
110
109
  const max = ctx.dim(formatPoint(Math.max(...points)));
111
- return `${min} ${sparkline(points)} ${max}${styledSuffix}`;
110
+ return `${min} ${sparkline(points, ctx.glyphs.sparkline)} ${max}${styledSuffix}`;
112
111
  }
113
112
  const labels = points.map((point) => formatPoint(point));
114
113
  const labelWidth = Math.max(...labels.map((label) => measureWidth(label)));
115
114
  const budget = Math.max(1, ctx.width - labelWidth - 1);
116
115
  const maxMagnitude = Math.max(...points.map((point) => Math.abs(point)));
117
116
  const lines = points.map((point, index) => {
118
- const bar = '█'.repeat(barLength(point, maxMagnitude, budget));
117
+ const bar = ctx.glyphs.barFilled.repeat(barLength(point, maxMagnitude, budget));
119
118
  return `${padText(labels[index], labelWidth, 'right')} ${bar}`;
120
119
  });
121
120
  return `${lines.join('\n')}${styledSuffix}`;
@@ -30,7 +30,7 @@ export function resolveDetailsInnerWidth(outerWidth) {
30
30
  export const Details = ({ attributes, children, ctx }) => {
31
31
  const title = attributes.title ?? DEFAULT_TITLE;
32
32
  const open = Object.hasOwn(attributes, 'open');
33
- const glyph = open ? '▾' : '▸';
33
+ const glyph = open ? ctx.glyphs.foldOpen : ctx.glyphs.foldClosed;
34
34
  const marker = ctx.dim(`${glyph} ${ctx.bold(ctx.text(title))} (collapsible section, shown expanded)`);
35
35
  const body = childrenText(children);
36
36
  if (!body)
@@ -1,3 +1,4 @@
1
+ import type { GlyphSet } from '../glyphs.js';
1
2
  import type { ReactNode } from 'react';
2
3
  import type { ColorLevel } from '../ansi.js';
3
4
  /**
@@ -26,7 +27,8 @@ export interface InteractiveDetailsProps {
26
27
  body: ReactNode;
27
28
  defaultOpen: boolean;
28
29
  color: ColorLevel;
30
+ glyphs: GlyphSet;
29
31
  interactive: boolean;
30
32
  focusId: number | undefined;
31
33
  }
32
- export declare function InteractiveDetails({ title, body, defaultOpen, color, interactive, focusId, }: InteractiveDetailsProps): ReactNode;
34
+ export declare function InteractiveDetails({ title, body, defaultOpen, color, glyphs, interactive, focusId, }: InteractiveDetailsProps): ReactNode;
@@ -2,10 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { bold, dim } from '../ansi.js';
5
- import { useFocusMarker, useIsFocused } from '../interactive.js';
6
- export function InteractiveDetails({ title, body, defaultOpen, color, interactive, focusId, }) {
5
+ import { useFocusAnchor, useFocusMarker, useIsFocused, } from '../interactive.js';
6
+ export function InteractiveDetails({ title, body, defaultOpen, color, glyphs, interactive, focusId, }) {
7
7
  const [open, setOpen] = useState(defaultOpen);
8
8
  const focused = useIsFocused(focusId);
9
+ const anchor = useFocusAnchor(focusId);
9
10
  useInput((_input, key) => {
10
11
  if (key.return)
11
12
  setOpen((current) => !current);
@@ -14,14 +15,14 @@ export function InteractiveDetails({ title, body, defaultOpen, color, interactiv
14
15
  // return below, so the hook order is identical in both modes. The summary
15
16
  // line is cheap to build and unused in the non-interactive branch; that
16
17
  // waste is the price of never making a hook call conditional.
17
- const openGlyph = open ? '▾' : '▸';
18
+ const openGlyph = open ? glyphs.foldOpen : glyphs.foldClosed;
18
19
  const hint = focused ? (open ? ' enter to close' : ' enter to open') : '';
19
20
  const summaryLine = dim(`${openGlyph} ${bold(title, color)}${hint}`, color);
20
- const focusGlyph = focused ? '› ' : ' ';
21
+ const focusGlyph = focused ? glyphs.focus : ' ';
21
22
  const headingLine = useFocusMarker(`${focusGlyph}${summaryLine}`, focusId);
22
23
  if (!interactive) {
23
- const glyph = defaultOpen ? '▾' : '▸';
24
+ const glyph = defaultOpen ? glyphs.foldOpen : glyphs.foldClosed;
24
25
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: dim(`${glyph} ${bold(title, color)} (collapsible section, shown expanded)`, color) }), _jsx(Box, { marginLeft: 2, flexDirection: "column", children: body })] }));
25
26
  }
26
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: headingLine }), open && (_jsx(Box, { marginLeft: 2, flexDirection: "column", children: body }))] }));
27
+ return (_jsxs(Box, { ref: anchor, flexDirection: "column", children: [_jsx(Text, { children: headingLine }), open && (_jsx(Box, { marginLeft: 2, flexDirection: "column", children: body }))] }));
27
28
  }
@@ -12,9 +12,7 @@ const LABEL_ALIGNS = ['left', 'center', 'right'];
12
12
  function isDividerLabelAlign(value) {
13
13
  return LABEL_ALIGNS.includes(value);
14
14
  }
15
- const ORNAMENT_GLYPH = '❖';
16
15
  /** The rule character each non-ornament variant draws with. */
17
- const RULE_CHAR = { line: '─', dots: '·' };
18
16
  /** How much of a labeled rule's rule fill sits on the SHORT side when the label is pushed left/right (the rest fills the long side). */
19
17
  const SHORT_SIDE_RULE = 2;
20
18
  /** Builds a full-`width` rule with `label` woven in at `align`, e.g. `── Part 2 ─────────`. */
@@ -64,12 +62,12 @@ export const Divider = ({ attributes, ctx }) => {
64
62
  let line;
65
63
  if (variant === 'ornament') {
66
64
  const inner = label
67
- ? `${ORNAMENT_GLYPH} ${label} ${ORNAMENT_GLYPH}`
68
- : ORNAMENT_GLYPH;
65
+ ? `${ctx.glyphs.ornament} ${label} ${ctx.glyphs.ornament}`
66
+ : ctx.glyphs.ornament;
69
67
  line = padText(inner, width, labelAlign);
70
68
  }
71
69
  else {
72
- const ruleChar = RULE_CHAR[variant];
70
+ const ruleChar = variant === 'dots' ? ctx.glyphs.ruleDots : ctx.glyphs.rule;
73
71
  line = label
74
72
  ? labeledRule(ruleChar, label, width, labelAlign)
75
73
  : ruleLine(width, ruleChar);
@@ -33,8 +33,6 @@ function readProgressFields(data) {
33
33
  return {};
34
34
  }
35
35
  /** Block characters the bar fills with, and its widest allowed footprint (columns), sized down further when the label/percent text leaves less room. */
36
- const FILLED_CHAR = '█';
37
- const EMPTY_CHAR = '░';
38
36
  const MAX_BAR_WIDTH = 30;
39
37
  const MIN_BAR_WIDTH = 3;
40
38
  /**
@@ -65,6 +63,7 @@ export const Progress = ({ attributes, ctx }) => {
65
63
  const overhead = measureWidth(labelPart) + measureWidth(percentText) + 1;
66
64
  const barWidth = Math.max(MIN_BAR_WIDTH, Math.min(MAX_BAR_WIDTH, ctx.width - overhead));
67
65
  const filledCount = Math.round((percent / 100) * barWidth);
68
- const bar = FILLED_CHAR.repeat(filledCount) + EMPTY_CHAR.repeat(barWidth - filledCount);
66
+ const bar = ctx.glyphs.barFilled.repeat(filledCount) +
67
+ ctx.glyphs.barEmpty.repeat(barWidth - filledCount);
69
68
  return `${labelPart}${bar}${percentPart}`;
70
69
  };
@@ -16,12 +16,12 @@ function clamp(value, min, max) {
16
16
  * degrades gracefully instead of throwing. Terminal form: filled (`★`) and
17
17
  * empty (`☆`) stars, one per position up to `max`.
18
18
  */
19
- export const Rating = ({ attributes }) => {
19
+ export const Rating = ({ attributes, ctx }) => {
20
20
  const max = clamp(parseCount(attributes.max, DEFAULT_MAX), MIN_MAX, MAX_MAX);
21
21
  const value = clamp(parseCount(attributes.value, 0), 0, max);
22
22
  let stars = '';
23
23
  for (let index = 0; index < max; index += 1) {
24
- stars += index < value ? '★' : '☆';
24
+ stars += index < value ? ctx.glyphs.starFilled : ctx.glyphs.starEmpty;
25
25
  }
26
26
  return stars;
27
27
  };
@@ -3,6 +3,8 @@ declare const TEXT_ALIGNS: readonly ["left", "center", "right"];
3
3
  export type RowTextAlign = (typeof TEXT_ALIGNS)[number];
4
4
  /** Below this width, cells stack vertically instead of sitting side by side — a named constant per the batch brief, not a magic number. */
5
5
  export declare const ROW_COLUMN_THRESHOLD = 60;
6
+ /** The blank columns between two cells of a grid row. Shared by both emitters (the Ink flex `Box` and the flattened string). */
7
+ export declare const ROW_GUTTER = 1;
6
8
  export interface RowLayout {
7
9
  align: RowTextAlign;
8
10
  columnCount: number;
@@ -8,6 +8,8 @@ function isTextAlign(value) {
8
8
  }
9
9
  /** Below this width, cells stack vertically instead of sitting side by side — a named constant per the batch brief, not a magic number. */
10
10
  export const ROW_COLUMN_THRESHOLD = 60;
11
+ /** The blank columns between two cells of a grid row. Shared by both emitters (the Ink flex `Box` and the flattened string). */
12
+ export const ROW_GUTTER = 1;
11
13
  /**
12
14
  * `:::row{cols=2|3|4 text=left|center|right} ... :::`'s pure layout
13
15
  * decision: how many columns, how wide each one is, and whether the row
@@ -30,7 +32,7 @@ export function resolveRowLayout(attributes, cellCount, width) {
30
32
  const rawCols = attributes.cols ?? '';
31
33
  const requestedCols = isColsValue(rawCols) ? Number(rawCols) : cellCount;
32
34
  const columnCount = Math.max(1, Math.min(requestedCols, Math.max(1, cellCount)));
33
- const gutter = 1;
35
+ const gutter = ROW_GUTTER;
34
36
  const stacked = width < ROW_COLUMN_THRESHOLD || columnCount <= 1;
35
37
  const colWidth = stacked
36
38
  ? width
@@ -1,7 +1,6 @@
1
1
  import { formatValue } from '@markii/stdlib';
2
2
  import { safeRead } from '../resolve.js';
3
3
  import { dataStateSuffix, failureToken } from '../failure-presentation.js';
4
- const EMPTY_VALUE = '—';
5
4
  const TRENDS = ['up', 'down', 'flat'];
6
5
  function isTrend(value) {
7
6
  return TRENDS.includes(value);
@@ -54,7 +53,7 @@ export const Stat = ({ attributes, ctx }) => {
54
53
  const lines = [];
55
54
  if (label)
56
55
  lines.push(ctx.style(ctx.text(label), '--mk-muted'));
57
- let valueLine = ctx.bold(ctx.text(formattedValue || EMPTY_VALUE));
56
+ let valueLine = ctx.bold(ctx.text(formattedValue || ctx.glyphs.emptyValue));
58
57
  if (delta) {
59
58
  const deltaToken = trend === 'up'
60
59
  ? '--mk-success'
@@ -1,3 +1,4 @@
1
+ import type { GlyphSet } from '../glyphs.js';
1
2
  /**
2
3
  * The ONE box-drawing/width-negotiation routine every table this engine
3
4
  * draws goes through: the data-bound `::table` component (`./table.ts`) and
@@ -29,6 +30,6 @@ export declare const MIN_COLUMN_WIDTH = 3;
29
30
  * complete grid at most `width` columns wide when the natural content
30
31
  * allows it, otherwise as narrow as `MIN_COLUMN_WIDTH` per column permits.
31
32
  */
32
- export declare function drawTableGrid(header: readonly string[] | undefined, rows: readonly (readonly string[])[], width: number, boldFn?: (text: string) => string, align?: 'left' | 'center' | 'right'): string;
33
+ export declare function drawTableGrid(header: readonly string[] | undefined, rows: readonly (readonly string[])[], width: number, glyphs: GlyphSet, boldFn?: (text: string) => string, align?: 'left' | 'center' | 'right'): string;
33
34
  /** The widest a `drawTableGrid` result's lines get, for a `selfLayout` component (`./table.ts`) that needs to know its own natural footprint (the `fit` width preset). */
34
35
  export declare function measureTableGridWidth(header: readonly string[] | undefined, rows: readonly (readonly string[])[]): number;
@@ -75,7 +75,7 @@ function wrapRow(row, widths) {
75
75
  const height = Math.max(1, ...lines.map((l) => l.length));
76
76
  return { lines, height };
77
77
  }
78
- function drawRow(row, widths, boldRow, boldFn, align) {
78
+ function drawRow(row, widths, boldRow, boldFn, align, glyphs) {
79
79
  const { lines, height } = wrapRow(row, widths);
80
80
  const rowLines = [];
81
81
  for (let line = 0; line < height; line += 1) {
@@ -83,12 +83,14 @@ function drawRow(row, widths, boldRow, boldFn, align) {
83
83
  const text = pad(lines[c]?.[line] ?? '', w, align);
84
84
  return boldRow ? boldFn(text) : text;
85
85
  });
86
- rowLines.push(`│ ${cells.join(' │ ')} │`);
86
+ const bar = glyphs.solid.vertical;
87
+ rowLines.push(`${bar} ${cells.join(` ${bar} `)} ${bar}`);
87
88
  }
88
89
  return rowLines.join('\n');
89
90
  }
90
- function drawSeparator(widths, left, mid, right) {
91
- return `${left}${widths.map((w) => '─'.repeat(w + CELL_PADDING)).join(mid)}${right}`;
91
+ function drawSeparator(widths, left, mid, right, glyphs) {
92
+ const rule = glyphs.solid.horizontal;
93
+ return `${left}${widths.map((w) => rule.repeat(w + CELL_PADDING)).join(mid)}${right}`;
92
94
  }
93
95
  /**
94
96
  * Draws a box-drawn table. `header`, when given, is the bold first row,
@@ -100,19 +102,20 @@ function drawSeparator(widths, left, mid, right) {
100
102
  * complete grid at most `width` columns wide when the natural content
101
103
  * allows it, otherwise as narrow as `MIN_COLUMN_WIDTH` per column permits.
102
104
  */
103
- export function drawTableGrid(header, rows, width, boldFn = (text) => text, align = 'left') {
105
+ export function drawTableGrid(header, rows, width, glyphs, boldFn = (text) => text, align = 'left') {
104
106
  const columnCount = Math.max(header?.length ?? 0, ...rows.map((row) => row.length), 1);
105
107
  const natural = naturalColumnWidths(header, rows, columnCount);
106
108
  const widths = negotiateColumnWidths(natural, width);
107
109
  const lines = [];
108
- lines.push(drawSeparator(widths, '┌', '┬', '┐'));
110
+ const g = glyphs;
111
+ lines.push(drawSeparator(widths, g.topLeft, g.teeDown, g.topRight, g));
109
112
  if (header) {
110
- lines.push(drawRow(header, widths, true, boldFn, align));
111
- lines.push(drawSeparator(widths, '├', '┼', '┤'));
113
+ lines.push(drawRow(header, widths, true, boldFn, align, g));
114
+ lines.push(drawSeparator(widths, g.teeRight, g.cross, g.teeLeft, g));
112
115
  }
113
116
  for (const row of rows)
114
- lines.push(drawRow(row, widths, false, boldFn, align));
115
- lines.push(drawSeparator(widths, '└', '┴', '┘'));
117
+ lines.push(drawRow(row, widths, false, boldFn, align, g));
118
+ lines.push(drawSeparator(widths, g.bottomLeft, g.teeUp, g.bottomRight, g));
116
119
  return lines.join('\n');
117
120
  }
118
121
  /** The widest a `drawTableGrid` result's lines get, for a `selfLayout` component (`./table.ts`) that needs to know its own natural footprint (the `fit` width preset). */
@@ -89,7 +89,7 @@ export const Table = ({ attributes, ctx }) => {
89
89
  }
90
90
  const naturalWidth = measureTableGridWidth(header, rows);
91
91
  const boxWidth = selfLayoutWidth(ctx.layout, ctx.width, naturalWidth);
92
- const grid = drawTableGrid(header, rows, boxWidth, (text) => ctx.bold(text), align);
92
+ const grid = drawTableGrid(header, rows, boxWidth, ctx.glyphs, (text) => ctx.bold(text), align);
93
93
  const body = `${grid}${styledSuffix}`;
94
94
  const withCaption = captionLine ? `${captionLine}\n${body}` : body;
95
95
  return selfLayoutAlign(withCaption, ctx.layout, ctx.width);
@@ -1,3 +1,4 @@
1
+ import type { GlyphSet } from '../glyphs.js';
1
2
  import type { ReactNode } from 'react';
2
3
  import type { ColorLevel } from '../ansi.js';
3
4
  import type { ElementTabPanel } from '../render.js';
@@ -27,7 +28,8 @@ import type { ElementTabPanel } from '../render.js';
27
28
  export interface InteractiveTabsProps {
28
29
  panels: readonly ElementTabPanel[];
29
30
  color: ColorLevel;
31
+ glyphs: GlyphSet;
30
32
  interactive: boolean;
31
33
  focusId: number | undefined;
32
34
  }
33
- export declare function InteractiveTabs({ panels, color, interactive, focusId, }: InteractiveTabsProps): ReactNode;
35
+ export declare function InteractiveTabs({ panels, color, glyphs, interactive, focusId, }: InteractiveTabsProps): ReactNode;
@@ -1,18 +1,26 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
2
+ import { useEffect, useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { bold, dim } from '../ansi.js';
5
- import { useFocusMarker, useIsFocused } from '../interactive.js';
6
- export function InteractiveTabs({ panels, color, interactive, focusId, }) {
5
+ import { useFocusAnchor, useFocusMarker, useIsFocused, useReportActiveLabel, } from '../interactive.js';
6
+ export function InteractiveTabs({ panels, color, glyphs, interactive, focusId, }) {
7
7
  const [active, setActive] = useState(0);
8
8
  const focused = useIsFocused(focusId);
9
+ const reportActiveLabel = useReportActiveLabel();
10
+ const anchor = useFocusAnchor(focusId);
9
11
  useInput((_input, key) => {
12
+ if (panels.length === 0)
13
+ return;
14
+ let nextIndex;
10
15
  if (key.leftArrow) {
11
- setActive((current) => (current - 1 + panels.length) % panels.length);
16
+ nextIndex = (active - 1 + panels.length) % panels.length;
12
17
  }
13
18
  else if (key.rightArrow || key.tab) {
14
- setActive((current) => (current + 1) % panels.length);
19
+ nextIndex = (active + 1) % panels.length;
15
20
  }
21
+ if (nextIndex === undefined)
22
+ return;
23
+ setActive(nextIndex);
16
24
  }, { isActive: interactive && focused });
17
25
  // Every hook this component calls runs BEFORE the non-interactive early
18
26
  // return below, so the hook order is identical in both modes. The header
@@ -20,6 +28,16 @@ export function InteractiveTabs({ panels, color, interactive, focusId, }) {
20
28
  // waste is the price of never making a hook call conditional.
21
29
  const activeIndex = Math.min(active, Math.max(0, panels.length - 1));
22
30
  const activePanel = panels[activeIndex];
31
+ const activeLabel = activePanel?.label ?? '';
32
+ // Tells the status line which panel is showing. Reported from an effect
33
+ // on the active label itself, so it also follows a re-parse (`--watch`)
34
+ // that changes the panels under a mounted block. The root ignores a
35
+ // report that repeats what it already has, so this cannot loop.
36
+ useEffect(() => {
37
+ if (interactive && focusId !== undefined) {
38
+ reportActiveLabel(focusId, activeLabel);
39
+ }
40
+ }, [interactive, focusId, activeLabel, reportActiveLabel]);
23
41
  const strip = panels
24
42
  .map((panel, index) => {
25
43
  const rendered = index === activeIndex
@@ -28,10 +46,10 @@ export function InteractiveTabs({ panels, color, interactive, focusId, }) {
28
46
  return index > 0 ? ` ${rendered}` : rendered;
29
47
  })
30
48
  .join('');
31
- const focusGlyph = focused ? '› ' : ' ';
49
+ const focusGlyph = focused ? glyphs.focus : ' ';
32
50
  const headingLine = useFocusMarker(`${focusGlyph}${strip}`, focusId);
33
51
  if (!interactive) {
34
52
  return (_jsx(Box, { flexDirection: "column", children: panels.map((panel, index) => (_jsxs(Box, { marginTop: index > 0 ? 1 : 0, flexDirection: "column", children: [_jsx(Text, { children: bold(`${panel.label}${index === 0 ? ' (active)' : ''}`, color) }), panel.body] }, index))) }));
35
53
  }
36
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: headingLine }), activePanel?.body] }));
54
+ return (_jsxs(Box, { ref: anchor, flexDirection: "column", children: [_jsx(Text, { children: headingLine }), activePanel?.body] }));
37
55
  }