@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
@@ -37,6 +37,10 @@ export declare function emptyInlineTitle(name: string): string;
37
37
  export declare function invalidAttributeValueTitle(directive: string, attribute: string, value: string): string;
38
38
  /** The marker text for a component-built image whose `src` was refused as unsafe. Wording identical to `@markii/html`'s `unsafeImageSrcTitle`. */
39
39
  export declare function unsafeImageSrcTitle(directive: string): string;
40
+ /** The diagnostic text for a host `renderCode` callback that threw; the code block rendered as usual. Wording identical to `@markii/html`'s `codeRendererFailedTitle`. */
41
+ export declare function codeRendererFailedTitle(): string;
42
+ /** The diagnostic text for a registered block component written as an inline directive. Wording identical to `@markii/html`'s `formMismatchTitle`. */
43
+ export declare function formMismatchTitle(directive: string): string;
40
44
  /**
41
45
  * The INLINE marker text for a known attribute's value outside its closed
42
46
  * enum, this engine's own departure from `@markii/html`'s identical wording:
@@ -56,3 +60,27 @@ export declare function invalidAttributeValueLabel(directive: string, attribute:
56
60
  * only.
57
61
  */
58
62
  export declare function unsafeImageSrcLabel(directive: string): string;
63
+ /**
64
+ * The words of a fallback label for a directive that names a component of
65
+ * an installed pack with no view in this engine. Followed by the directive
66
+ * name in backticks, like the other fallback labels.
67
+ */
68
+ export declare const NO_ENGINE_VIEW_LABEL = "no terminal view for";
69
+ /**
70
+ * The diagnostic text for the same case: the pack, and the engines the
71
+ * component does ship a view for. A terminal has no tooltip, so this
72
+ * sentence reaches the host's diagnostics surface through `onDiagnostic`
73
+ * and is never printed in the note.
74
+ */
75
+ export declare function noEngineViewTitle(directive: string, pack: string, engines: readonly string[]): string;
76
+ /**
77
+ * The fallback label for a directive of a pack whose terminal view script
78
+ * could not be loaded. Followed by the directive name in backticks, like the
79
+ * other fallback labels. `pack` is stripped by the caller's sanitizer.
80
+ */
81
+ export declare function packLoadFailedLabel(pack: string): string;
82
+ /**
83
+ * The diagnostic text for the same case. The loader's own line (what threw)
84
+ * is on the diagnostics surface already; this one names the directive.
85
+ */
86
+ export declare function packLoadFailedTitle(directive: string, pack: string): string;
@@ -99,6 +99,14 @@ export function invalidAttributeValueTitle(directive, attribute, value) {
99
99
  export function unsafeImageSrcTitle(directive) {
100
100
  return `${directive}: image source was refused as unsafe and was not shown`;
101
101
  }
102
+ /** The diagnostic text for a host `renderCode` callback that threw; the code block rendered as usual. Wording identical to `@markii/html`'s `codeRendererFailedTitle`. */
103
+ export function codeRendererFailedTitle() {
104
+ return 'code block: the host code renderer failed, so the plain block was shown';
105
+ }
106
+ /** The diagnostic text for a registered block component written as an inline directive. Wording identical to `@markii/html`'s `formMismatchTitle`. */
107
+ export function formMismatchTitle(directive) {
108
+ return `${directive}: block component was written inline and was not rendered`;
109
+ }
102
110
  /**
103
111
  * The INLINE marker text for a known attribute's value outside its closed
104
112
  * enum, this engine's own departure from `@markii/html`'s identical wording:
@@ -122,3 +130,36 @@ export function invalidAttributeValueLabel(directive, attribute) {
122
130
  export function unsafeImageSrcLabel(directive) {
123
131
  return `${directive}: image not shown`;
124
132
  }
133
+ /**
134
+ * The words of a fallback label for a directive that names a component of
135
+ * an installed pack with no view in this engine. Followed by the directive
136
+ * name in backticks, like the other fallback labels.
137
+ */
138
+ export const NO_ENGINE_VIEW_LABEL = 'no terminal view for';
139
+ /**
140
+ * The diagnostic text for the same case: the pack, and the engines the
141
+ * component does ship a view for. A terminal has no tooltip, so this
142
+ * sentence reaches the host's diagnostics surface through `onDiagnostic`
143
+ * and is never printed in the note.
144
+ */
145
+ export function noEngineViewTitle(directive, pack, engines) {
146
+ const has = engines.length > 0
147
+ ? `it has views for: ${engines.join(', ')}`
148
+ : 'it has no views for any engine';
149
+ return `The ${pack} pack has no terminal view for this component; ${has}`;
150
+ }
151
+ /**
152
+ * The fallback label for a directive of a pack whose terminal view script
153
+ * could not be loaded. Followed by the directive name in backticks, like the
154
+ * other fallback labels. `pack` is stripped by the caller's sanitizer.
155
+ */
156
+ export function packLoadFailedLabel(pack) {
157
+ return `the \`${pack}\` pack failed to load, no terminal view for`;
158
+ }
159
+ /**
160
+ * The diagnostic text for the same case. The loader's own line (what threw)
161
+ * is on the diagnostics surface already; this one names the directive.
162
+ */
163
+ export function packLoadFailedTitle(directive, pack) {
164
+ return `The ${pack} pack failed to load in the terminal, so ${directive} has no view`;
165
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Every glyph this engine draws on its own, as one table per box style.
3
+ * `unicode` is the default; `ascii` is a 7-bit form for a terminal that
4
+ * cannot show box drawing or symbols. Every component, frame, rule, and
5
+ * status-line string reads its glyphs from the set the render was given,
6
+ * so the two styles cannot drift apart: `glyphs.test.ts` renders the whole
7
+ * render corpus in `ascii` and fails on any byte above 0x7f that did not
8
+ * come from the note itself.
9
+ *
10
+ * Each entry occupies the same number of terminal columns in both sets
11
+ * where layout depends on it (frames, rules, bars, focus and fold markers),
12
+ * so switching style never changes how a note wraps. The engine never
13
+ * picks a style from the environment; the host decides.
14
+ */
15
+ export declare const BOX_STYLES: readonly ["unicode", "ascii"];
16
+ export type BoxStyle = (typeof BOX_STYLES)[number];
17
+ export declare function isBoxStyle(value: string): value is BoxStyle;
18
+ /** One frame edge set: the solid frame of a card, or the dashed one of a fallback box. */
19
+ export interface FrameEdges {
20
+ readonly horizontal: string;
21
+ readonly vertical: string;
22
+ }
23
+ export interface GlyphSet {
24
+ /** Corners shared by every frame and by a table's outer edge. */
25
+ readonly topLeft: string;
26
+ readonly topRight: string;
27
+ readonly bottomLeft: string;
28
+ readonly bottomRight: string;
29
+ readonly solid: FrameEdges;
30
+ readonly dashed: FrameEdges;
31
+ /** A table's inner junctions. */
32
+ readonly teeDown: string;
33
+ readonly teeUp: string;
34
+ readonly teeRight: string;
35
+ readonly teeLeft: string;
36
+ readonly cross: string;
37
+ /** A thematic break and a `divider` drawn as a line. */
38
+ readonly rule: string;
39
+ /** A `divider` drawn as dots. */
40
+ readonly ruleDots: string;
41
+ /** A `divider{style=ornament}` centerpiece. */
42
+ readonly ornament: string;
43
+ /** The bar a blockquote's lines start with, including its trailing space. */
44
+ readonly quoteBar: string;
45
+ /** The bar a callout's lines start with, including its trailing space. */
46
+ readonly calloutBar: string;
47
+ readonly calloutIcons: {
48
+ readonly info: string;
49
+ readonly warning: string;
50
+ readonly danger: string;
51
+ };
52
+ /** The marker before a focused block's heading, including its trailing space. */
53
+ readonly focus: string;
54
+ /** A details block's fold marker. */
55
+ readonly foldOpen: string;
56
+ readonly foldClosed: string;
57
+ /** The collapsed script marker: its leading symbol and the separator before its detail. */
58
+ readonly script: string;
59
+ readonly scriptSeparator: string;
60
+ /** Eight steps, lowest first, that a line chart's sparkline is built from. */
61
+ readonly sparkline: string;
62
+ /** A filled bar cell (chart bars, a progress bar's done part) and an empty one. */
63
+ readonly barFilled: string;
64
+ readonly barEmpty: string;
65
+ readonly starFilled: string;
66
+ readonly starEmpty: string;
67
+ /** What a `stat` shows when its value is empty. */
68
+ readonly emptyValue: string;
69
+ /** The key legend on the live viewer's status line. */
70
+ readonly keyLegend: string;
71
+ }
72
+ /** The glyph set for `style`; anything other than `'ascii'` gets the unicode set. */
73
+ export declare function glyphsFor(style: BoxStyle | undefined): GlyphSet;
package/dist/glyphs.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Every glyph this engine draws on its own, as one table per box style.
3
+ * `unicode` is the default; `ascii` is a 7-bit form for a terminal that
4
+ * cannot show box drawing or symbols. Every component, frame, rule, and
5
+ * status-line string reads its glyphs from the set the render was given,
6
+ * so the two styles cannot drift apart: `glyphs.test.ts` renders the whole
7
+ * render corpus in `ascii` and fails on any byte above 0x7f that did not
8
+ * come from the note itself.
9
+ *
10
+ * Each entry occupies the same number of terminal columns in both sets
11
+ * where layout depends on it (frames, rules, bars, focus and fold markers),
12
+ * so switching style never changes how a note wraps. The engine never
13
+ * picks a style from the environment; the host decides.
14
+ */
15
+ export const BOX_STYLES = ['unicode', 'ascii'];
16
+ export function isBoxStyle(value) {
17
+ return BOX_STYLES.includes(value);
18
+ }
19
+ const UNICODE = {
20
+ topLeft: '┌',
21
+ topRight: '┐',
22
+ bottomLeft: '└',
23
+ bottomRight: '┘',
24
+ solid: { horizontal: '─', vertical: '│' },
25
+ dashed: { horizontal: '╌', vertical: '┆' },
26
+ teeDown: '┬',
27
+ teeUp: '┴',
28
+ teeRight: '├',
29
+ teeLeft: '┤',
30
+ cross: '┼',
31
+ rule: '─',
32
+ ruleDots: '·',
33
+ ornament: '❖',
34
+ quoteBar: '│ ',
35
+ calloutBar: '▌ ',
36
+ calloutIcons: { info: 'ℹ', warning: '▲', danger: '✕' },
37
+ focus: '› ',
38
+ foldOpen: '▾',
39
+ foldClosed: '▸',
40
+ script: '⚙',
41
+ scriptSeparator: '·',
42
+ sparkline: '▁▂▃▄▅▆▇█',
43
+ barFilled: '█',
44
+ barEmpty: '░',
45
+ starFilled: '★',
46
+ starEmpty: '☆',
47
+ emptyValue: '—',
48
+ keyLegend: 'q quit ↑↓/jk focus ←→/tab switch enter open/close space/pgdn scroll g/G top/end',
49
+ };
50
+ const ASCII = {
51
+ topLeft: '+',
52
+ topRight: '+',
53
+ bottomLeft: '+',
54
+ bottomRight: '+',
55
+ solid: { horizontal: '-', vertical: '|' },
56
+ dashed: { horizontal: '.', vertical: ':' },
57
+ teeDown: '+',
58
+ teeUp: '+',
59
+ teeRight: '+',
60
+ teeLeft: '+',
61
+ cross: '+',
62
+ rule: '-',
63
+ ruleDots: '.',
64
+ ornament: '*',
65
+ quoteBar: '| ',
66
+ calloutBar: '| ',
67
+ calloutIcons: { info: 'i', warning: '!', danger: 'x' },
68
+ focus: '> ',
69
+ foldOpen: '-',
70
+ foldClosed: '+',
71
+ script: '#',
72
+ scriptSeparator: '-',
73
+ sparkline: '_.-:=+*#',
74
+ barFilled: '#',
75
+ barEmpty: '.',
76
+ starFilled: '*',
77
+ starEmpty: '.',
78
+ emptyValue: '-',
79
+ keyLegend: 'q quit up/down/jk focus left/right/tab switch enter open/close space/pgdn scroll g/G top/end',
80
+ };
81
+ const GLYPHS = {
82
+ unicode: UNICODE,
83
+ ascii: ASCII,
84
+ };
85
+ /** The glyph set for `style`; anything other than `'ascii'` gets the unicode set. */
86
+ export function glyphsFor(style) {
87
+ return style === 'ascii' ? GLYPHS.ascii : GLYPHS.unicode;
88
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- export { renderMarkToAnsi, renderMarkNodeToAnsi, renderMarkInlineToAnsi, buildMarkElement, type RenderMarkOptions, } from './render.js';
1
+ export { renderMarkToAnsi, renderMarkNodeToAnsi, renderMarkInlineToAnsi, buildMarkElement, type RenderCode, type RenderCodeBlock, type RenderMarkOptions, } from './render.js';
2
2
  export { fg, colorize, bold, dim, italic, underline, inverse, hyperlink, detectColorLevel, resolveColorOption, type ColorLevel, type ColorOption, type AnsiColor, } from './ansi.js';
3
3
  export { defaultAnsiTheme, type AnsiTheme, type Tier1Token } from './theme.js';
4
+ export { BOX_STYLES, isBoxStyle, glyphsFor, type BoxStyle, type FrameEdges, type GlyphSet, } from './glyphs.js';
4
5
  export { style } from './style.js';
5
6
  export { createAnsiRegistry, mergeAnsiRegistries, registryAliases, readRegistryComponent, resolveDirectiveAlias, childrenText, REGISTRY_ALIASES, type DirectiveAttributes, type AnsiRegistry, type AnsiRegistryEntry, type AnsiComponent, type AnsiComponentProps, type AnsiRenderContext, type RegistryAlias, type RegistryAliases, type ResolvedDirective, type ValueResolution, } from './registry.js';
6
7
  export { resolveStorePath, resolveScopedPath, VAULT_NAME_PREFIX, type StorePathResolution, type ValueScope, } from './resolve.js';
@@ -11,3 +12,6 @@ export { resolveLayoutAttributes, applyLayout, LAYOUT_ATTRIBUTE_KEYS, type Resol
11
12
  export { type ResolveImageSrc } from './image-resolve.js';
12
13
  export { type ResolveHref } from './href-resolve.js';
13
14
  export type { AnsiValueStore, AnsiVaultStore, FailureKind, StoredValue, ValueStatus, } from './value-types.js';
15
+ export { defaultAnsiRegistry } from './components/index.js';
16
+ export { ANSI_ENGINE_ID, loadAnsiPack, installAnsiPacks, sanitizeValueForPack, type AnsiPackComponentModules, type AnsiPackToInstall, type LoadAnsiPackResult, type InstallAnsiPacksResult, } from './pack-loader.js';
17
+ export { loadTerminalPackScript, installTerminalPackGlobals, TERMINAL_REGISTER_GLOBAL, TERMINAL_REACT_GLOBAL, TERMINAL_INK_GLOBAL, type LoadTerminalPackScriptResult, } from './pack-script.js';
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  export { renderMarkToAnsi, renderMarkNodeToAnsi, renderMarkInlineToAnsi, buildMarkElement, } from './render.js';
8
8
  export { fg, colorize, bold, dim, italic, underline, inverse, hyperlink, detectColorLevel, resolveColorOption, } from './ansi.js';
9
9
  export { defaultAnsiTheme } from './theme.js';
10
+ export { BOX_STYLES, isBoxStyle, glyphsFor, } from './glyphs.js';
10
11
  export { style } from './style.js';
11
12
  export { createAnsiRegistry, mergeAnsiRegistries, registryAliases, readRegistryComponent, resolveDirectiveAlias, childrenText, REGISTRY_ALIASES, } from './registry.js';
12
13
  export { resolveStorePath, resolveScopedPath, VAULT_NAME_PREFIX, } from './resolve.js';
@@ -14,3 +15,6 @@ export { failurePhrase, failureTitle, failureToken, dataStateSuffix, emptyInline
14
15
  export { stringifyStoredValue } from './value-format.js';
15
16
  export { stripControlCharacters, sanitizeBlockText, sanitizeUrlText, BLOCK_TAB_WIDTH, } from './sanitize.js';
16
17
  export { resolveLayoutAttributes, applyLayout, LAYOUT_ATTRIBUTE_KEYS, } from './layout.js';
18
+ export { defaultAnsiRegistry } from './components/index.js';
19
+ export { ANSI_ENGINE_ID, loadAnsiPack, installAnsiPacks, sanitizeValueForPack, } from './pack-loader.js';
20
+ export { loadTerminalPackScript, installTerminalPackGlobals, TERMINAL_REGISTER_GLOBAL, TERMINAL_REACT_GLOBAL, TERMINAL_INK_GLOBAL, } from './pack-script.js';
@@ -10,3 +10,14 @@ import type { ReactElement } from 'react';
10
10
  * the ordinary ESM module-cache guarantee.
11
11
  */
12
12
  export declare function renderInkToString(element: ReactElement, columns: number): Promise<string>;
13
+ /**
14
+ * The same capture as `renderInkToString`, synchronously. Valid only once
15
+ * Ink's module has finished evaluating, which is always true inside a walk
16
+ * (`render.tsx` imports Ink statically). Used for a registry component that
17
+ * returns an Ink element instead of a string (a terminal pack's view): the
18
+ * element is laid out at the width the directive was given and captured as
19
+ * text, so it composes with the string-built blocks around it. The result
20
+ * is a snapshot, so a component rendered this way has no live state.
21
+ * Trailing newlines Ink adds are removed.
22
+ */
23
+ export declare function renderInkToStringSync(element: ReactElement, columns: number): string;
@@ -1,4 +1,5 @@
1
1
  import { Writable } from 'node:stream';
2
+ import { render as inkRender } from 'ink';
2
3
  function makeCollectingStream(columns) {
3
4
  const chunks = [];
4
5
  const stream = new Writable({
@@ -39,3 +40,26 @@ export async function renderInkToString(element, columns) {
39
40
  instance.unmount();
40
41
  return getOutput();
41
42
  }
43
+ /**
44
+ * The same capture as `renderInkToString`, synchronously. Valid only once
45
+ * Ink's module has finished evaluating, which is always true inside a walk
46
+ * (`render.tsx` imports Ink statically). Used for a registry component that
47
+ * returns an Ink element instead of a string (a terminal pack's view): the
48
+ * element is laid out at the width the directive was given and captured as
49
+ * text, so it composes with the string-built blocks around it. The result
50
+ * is a snapshot, so a component rendered this way has no live state.
51
+ * Trailing newlines Ink adds are removed.
52
+ */
53
+ export function renderInkToStringSync(element, columns) {
54
+ const { stream, getOutput } = makeCollectingStream(columns);
55
+ const instance = inkRender(element, {
56
+ stdout: stream,
57
+ stdin: process.stdin,
58
+ exitOnCtrlC: false,
59
+ patchConsole: false,
60
+ debug: false,
61
+ interactive: false,
62
+ });
63
+ instance.unmount();
64
+ return getOutput().replace(/\n+$/, '');
65
+ }
@@ -1,18 +1,32 @@
1
1
  import type { ReactNode } from 'react';
2
+ import type { DOMElement } from 'ink';
2
3
  import type { AnsiTheme } from './theme.js';
3
4
  import type { ColorLevel } from './ansi.js';
4
5
  /** Whether the component identified by `focusId` is the currently focused one. `undefined` (a non-interactive render, or a component that never got a `focusId`) is never focused. */
5
6
  export declare function useIsFocused(focusId: number | undefined): boolean;
6
7
  /** Wraps `text` in the accent-colored focus marker when interactive AND `focusId` is the focused one; otherwise returns `text` unchanged. */
7
8
  export declare function useFocusMarker(text: string, focusId: number | undefined): string;
9
+ /**
10
+ * Lets a `tabs` instance report its currently active panel's label to
11
+ * `InteractiveRoot`, so the status line names the panel showing rather than
12
+ * the block's first one. Call it from an effect, never during render (see
13
+ * `FocusApi.reportActiveLabel`).
14
+ */
15
+ export declare function useReportActiveLabel(): (focusId: number, label: string) => void;
16
+ /**
17
+ * The ref callback a focusable component puts on its outermost `Box`, so
18
+ * the live viewer can scroll the focused block into view. A no-op without a
19
+ * `focusId` or outside an interactive render.
20
+ */
21
+ export declare function useFocusAnchor(focusId: number | undefined): (node: DOMElement | null) => void;
8
22
  /**
9
23
  * One focusable component, in the same document order `render.tsx`'s
10
24
  * counting walk assigns `focusId`s in — `focusables[focusId]` is always
11
- * that component's descriptor. `label` is a stable identity: for `tabs`,
12
- * the FIRST panel's label, never the currently active one. A `tabs` block's
13
- * active panel is its own component state, so naming it on the status line
14
- * would need a report channel back up to this root on every switch; the
15
- * first label identifies the block just as well and cannot loop.
25
+ * that component's descriptor. `label` is the descriptor's STABLE identity
26
+ * (for `tabs`, the FIRST panel's label): it is used on the status line only
27
+ * until a `tabs` instance reports its actual active panel via
28
+ * `reportActiveLabel`, and as the fallback before any report has happened
29
+ * yet (mount, or a reader who has not switched panels).
16
30
  */
17
31
  export interface FocusableDescriptor {
18
32
  kind: 'tabs' | 'details';
@@ -25,6 +39,17 @@ export interface InteractiveRootProps {
25
39
  onExit?: () => void;
26
40
  color: ColorLevel;
27
41
  theme: AnsiTheme;
42
+ /** The key legend on the status line, identical regardless of what is focused: the render's `GlyphSet.keyLegend`, so it follows the box style. */
43
+ keyLegend: string;
44
+ /**
45
+ * The terminal's row count. When given, the document gets a viewport of
46
+ * this many rows minus the one-row status line, scrolled with the keys in
47
+ * the legend, and the status line stays pinned at the bottom. Absent, the
48
+ * document is laid out at its full height with the status line after it.
49
+ */
50
+ viewportRows?: number;
51
+ /** The columns the status line is cut to, so it stays one row. */
52
+ width: number;
28
53
  children: ReactNode;
29
54
  }
30
55
  /**
@@ -38,4 +63,4 @@ export interface InteractiveRootProps {
38
63
  * default (uninteractive) value there, so a string render never sees a
39
64
  * focus marker, a glyph, or a status line.
40
65
  */
41
- export declare function InteractiveRoot({ focusables, onExit, color, theme, children, }: InteractiveRootProps): ReactNode;
66
+ export declare function InteractiveRoot({ focusables, onExit, color, theme, keyLegend, viewportRows, width, children, }: InteractiveRootProps): ReactNode;
@@ -1,12 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createContext, useContext, useMemo, useState } from 'react';
3
- import { Box, Text, useInput } from 'ink';
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
3
+ import { Box, Text, measureElement, useInput } from 'ink';
4
4
  import { dim } from './ansi.js';
5
5
  import { style } from './style.js';
6
+ import { cutToWidth } from './text-grid.js';
6
7
  const FocusContext = createContext({
7
8
  interactive: false,
8
9
  focusedId: undefined,
9
10
  focusMarker: (text) => text,
11
+ reportActiveLabel: () => { },
12
+ registerAnchor: () => { },
10
13
  });
11
14
  /** Whether the component identified by `focusId` is the currently focused one. `undefined` (a non-interactive render, or a component that never got a `focusId`) is never focused. */
12
15
  export function useIsFocused(focusId) {
@@ -19,8 +22,29 @@ export function useFocusMarker(text, focusId) {
19
22
  const focused = focusId !== undefined && api.interactive && api.focusedId === focusId;
20
23
  return focused ? api.focusMarker(text) : text;
21
24
  }
22
- /** The key legend shown on the status line, identical regardless of what is focused. */
23
- const KEY_LEGEND = '↑↓/jk focus ←→/tab switch enter open/close q quit';
25
+ /**
26
+ * Lets a `tabs` instance report its currently active panel's label to
27
+ * `InteractiveRoot`, so the status line names the panel showing rather than
28
+ * the block's first one. Call it from an effect, never during render (see
29
+ * `FocusApi.reportActiveLabel`).
30
+ */
31
+ export function useReportActiveLabel() {
32
+ const api = useContext(FocusContext);
33
+ return api.reportActiveLabel;
34
+ }
35
+ /**
36
+ * The ref callback a focusable component puts on its outermost `Box`, so
37
+ * the live viewer can scroll the focused block into view. A no-op without a
38
+ * `focusId` or outside an interactive render.
39
+ */
40
+ export function useFocusAnchor(focusId) {
41
+ const { registerAnchor } = useContext(FocusContext);
42
+ return useCallback((node) => {
43
+ if (focusId === undefined)
44
+ return;
45
+ registerAnchor(focusId, node ?? undefined);
46
+ }, [focusId, registerAnchor]);
47
+ }
24
48
  /**
25
49
  * The root of an interactive render: owns which `focusId` is focused, moves
26
50
  * it on up/down/j/k, calls `onExit` on `q`, and renders the one-line status
@@ -32,33 +56,152 @@ const KEY_LEGEND = '↑↓/jk focus ←→/tab switch enter open/close q quit
32
56
  * default (uninteractive) value there, so a string render never sees a
33
57
  * focus marker, a glyph, or a status line.
34
58
  */
35
- export function InteractiveRoot({ focusables, onExit, color, theme, children, }) {
59
+ export function InteractiveRoot({ focusables, onExit, color, theme, keyLegend, viewportRows, width, children, }) {
36
60
  const focusableCount = focusables.length;
37
61
  const [focusIndex, setFocusIndex] = useState(0);
62
+ // `focusId -> the label its last active-panel report carried`. Only a
63
+ // `tabs` descriptor is ever reported against; `details` never calls
64
+ // `reportActiveLabel`, so its entry here simply never appears. Absent
65
+ // keys are expected (mount, or a `tabs` the reader has not switched yet)
66
+ // and fall back to the descriptor's own stable `label` below.
67
+ const [activeLabels, setActiveLabels] = useState({});
68
+ const viewport = viewportRows !== undefined ? Math.max(1, viewportRows - 1) : undefined;
69
+ const [offset, setOffset] = useState(0);
70
+ const contentRef = useRef(null);
71
+ const anchors = useRef(new Map());
72
+ // What the last reveal pass saw, so a reveal runs when focus MOVES or the
73
+ // focused block changes height (a details opening), and not on every
74
+ // render: a reader who scrolled away on purpose is left where they are.
75
+ const lastRevealed = useRef({
76
+ id: undefined,
77
+ height: -1,
78
+ });
79
+ const contentHeight = () => contentRef.current ? measureElement(contentRef.current).height : 0;
80
+ const maxOffset = () => viewport === undefined ? 0 : Math.max(0, contentHeight() - viewport);
81
+ const scrollBy = (delta) => {
82
+ if (viewport === undefined)
83
+ return;
84
+ const limit = maxOffset();
85
+ setOffset((current) => Math.min(limit, Math.max(0, current + delta)));
86
+ };
87
+ const scrollTo = (target) => {
88
+ if (viewport === undefined)
89
+ return;
90
+ setOffset(target === 'top' ? 0 : maxOffset());
91
+ };
38
92
  useInput((input, key) => {
39
93
  if (input === 'q') {
40
94
  onExit?.();
41
95
  return;
42
96
  }
43
- if (focusableCount === 0)
97
+ if (viewport !== undefined) {
98
+ const page = Math.max(1, viewport - 1);
99
+ if (key.pageDown || input === ' ' || (key.ctrl && input === 'f')) {
100
+ scrollBy(page);
101
+ return;
102
+ }
103
+ if (key.pageUp || input === 'b' || (key.ctrl && input === 'b')) {
104
+ scrollBy(-page);
105
+ return;
106
+ }
107
+ if (key.home || input === 'g') {
108
+ scrollTo('top');
109
+ return;
110
+ }
111
+ if (key.end || input === 'G') {
112
+ scrollTo('end');
113
+ return;
114
+ }
115
+ if (key.ctrl && input === 'e') {
116
+ scrollBy(1);
117
+ return;
118
+ }
119
+ if (key.ctrl && input === 'y') {
120
+ scrollBy(-1);
121
+ return;
122
+ }
123
+ }
124
+ const down = key.downArrow || input === 'j';
125
+ const up = key.upArrow || input === 'k';
126
+ if (focusableCount === 0) {
127
+ // Nothing to focus: the same keys scroll a line at a time.
128
+ if (down)
129
+ scrollBy(1);
130
+ else if (up)
131
+ scrollBy(-1);
44
132
  return;
45
- if (key.downArrow || input === 'j') {
133
+ }
134
+ if (down) {
46
135
  setFocusIndex((current) => (current + 1) % focusableCount);
47
136
  }
48
- else if (key.upArrow || input === 'k') {
137
+ else if (up) {
49
138
  setFocusIndex((current) => (current - 1 + focusableCount) % focusableCount);
50
139
  }
51
140
  }, { isActive: true });
52
141
  const focusedId = focusableCount > 0 ? focusIndex % focusableCount : undefined;
142
+ // Invoked from `tabs.tsx`'s KEY HANDLER, never during render — see
143
+ // `FocusApi.reportActiveLabel`'s doc comment. The equality guard avoids an
144
+ // unnecessary re-render when a switch reports the label it already had
145
+ // (e.g. wrapping from the last panel back past the first is still a real
146
+ // change, but re-reporting the same active panel is not).
147
+ const reportActiveLabel = useCallback((focusId, label) => {
148
+ setActiveLabels((current) => current[focusId] === label ? current : { ...current, [focusId]: label });
149
+ }, []);
150
+ const registerAnchor = useCallback((focusId, node) => {
151
+ if (node)
152
+ anchors.current.set(focusId, node);
153
+ else
154
+ anchors.current.delete(focusId);
155
+ }, []);
53
156
  const api = useMemo(() => ({
54
157
  interactive: true,
55
158
  focusedId,
56
159
  focusMarker: (text) => style(text, '--mk-accent', theme, color),
57
- }), [focusedId, color, theme]);
160
+ reportActiveLabel,
161
+ registerAnchor,
162
+ }), [focusedId, color, theme, reportActiveLabel, registerAnchor]);
163
+ // After every commit Ink has laid the tree out, so this is where the
164
+ // viewport is kept honest: the offset never exceeds the document (a
165
+ // resize or a closing details can shrink it), and a focus move, or a
166
+ // focused block growing, scrolls that block into view.
167
+ useEffect(() => {
168
+ if (viewport === undefined || !contentRef.current)
169
+ return;
170
+ const content = measureElement(contentRef.current);
171
+ const limit = Math.max(0, content.height - viewport);
172
+ let next = Math.min(offset, limit);
173
+ const anchor = focusedId !== undefined ? anchors.current.get(focusedId) : undefined;
174
+ if (anchor) {
175
+ const box = measureElement(anchor);
176
+ const seen = lastRevealed.current;
177
+ if (seen.id !== focusedId || seen.height !== box.height) {
178
+ lastRevealed.current = { id: focusedId, height: box.height };
179
+ const top = box.y - content.y;
180
+ const bottom = top + box.height;
181
+ if (top < next)
182
+ next = top;
183
+ else if (bottom > next + viewport)
184
+ next = Math.min(top, bottom - viewport);
185
+ next = Math.min(limit, Math.max(0, next));
186
+ }
187
+ }
188
+ if (next !== offset)
189
+ setOffset(next);
190
+ });
58
191
  const focused = focusedId !== undefined ? focusables[focusedId] : undefined;
192
+ const focusedLabel = focused && focusedId !== undefined
193
+ ? (activeLabels[focusedId] ?? focused.label)
194
+ : undefined;
59
195
  const statusLabel = focused
60
- ? `${focused.kind} "${focused.label}"`
196
+ ? `${focused.kind} "${focusedLabel}"`
61
197
  : 'nothing focusable';
62
- const statusLine = dim(`${statusLabel} ${KEY_LEGEND}`, color);
63
- return (_jsx(FocusContext.Provider, { value: api, children: _jsxs(Box, { flexDirection: "column", children: [children, _jsx(Text, { children: statusLine })] }) }));
198
+ // Cut to the width on plain text: the status line must stay one row (the
199
+ // viewport math counts it as one), and a label or legend that wrapped
200
+ // would push the document up.
201
+ const statusLine = dim(cutToWidth(`${statusLabel} ${keyLegend}`, width), color);
202
+ const status = _jsx(Text, { children: statusLine });
203
+ if (viewport === undefined) {
204
+ return (_jsx(FocusContext.Provider, { value: api, children: _jsxs(Box, { flexDirection: "column", children: [children, status] }) }));
205
+ }
206
+ return (_jsx(FocusContext.Provider, { value: api, children: _jsxs(Box, { flexDirection: "column", height: viewport + 1, children: [_jsx(Box, { flexDirection: "column", height: viewport, flexShrink: 0, overflow: "hidden", children: _jsx(Box, { ref: contentRef, flexDirection: "column", flexShrink: 0, marginTop: -offset, children: children }) }), status] }) }));
64
207
  }