@kolisachint/hoocode-tui 0.5.66 → 0.5.68

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 (39) hide show
  1. package/dist/components/editor.d.ts +20 -18
  2. package/dist/components/editor.d.ts.map +1 -1
  3. package/dist/components/editor.js +54 -58
  4. package/dist/components/editor.js.map +1 -1
  5. package/dist/components/frame.d.ts +117 -0
  6. package/dist/components/frame.d.ts.map +1 -0
  7. package/dist/components/frame.js +203 -0
  8. package/dist/components/frame.js.map +1 -0
  9. package/dist/components/select-list.d.ts.map +1 -1
  10. package/dist/components/select-list.js +8 -2
  11. package/dist/components/select-list.js.map +1 -1
  12. package/dist/components/settings-list.d.ts.map +1 -1
  13. package/dist/components/settings-list.js +7 -2
  14. package/dist/components/settings-list.js.map +1 -1
  15. package/dist/index.d.ts +3 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +4 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/keybindings.d.ts +9 -4
  20. package/dist/keybindings.d.ts.map +1 -1
  21. package/dist/keybindings.js +14 -2
  22. package/dist/keybindings.js.map +1 -1
  23. package/dist/mouse.d.ts +75 -0
  24. package/dist/mouse.d.ts.map +1 -0
  25. package/dist/mouse.js +115 -0
  26. package/dist/mouse.js.map +1 -0
  27. package/dist/terminal.d.ts +30 -0
  28. package/dist/terminal.d.ts.map +1 -1
  29. package/dist/terminal.js +49 -0
  30. package/dist/terminal.js.map +1 -1
  31. package/dist/tui.d.ts +286 -0
  32. package/dist/tui.d.ts.map +1 -1
  33. package/dist/tui.js +642 -1
  34. package/dist/tui.js.map +1 -1
  35. package/dist/undo-stack.d.ts +39 -6
  36. package/dist/undo-stack.d.ts.map +1 -1
  37. package/dist/undo-stack.js +59 -6
  38. package/dist/undo-stack.js.map +1 -1
  39. package/package.json +1 -1
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The frame the prompt box draws, and the one every surface that takes its
3
+ * place draws with it.
4
+ *
5
+ * The editor owned this border alone for a long time, and every other surface
6
+ * that replaces the editor — the pickers, the options pane, the extension
7
+ * input and editor, the login dialog — drew two bare rules instead. Two rules
8
+ * is not the same shape as a box: the content sat flush at column 0 where the
9
+ * editor insets it, nothing named the surface, and switching the editor to
10
+ * `rule` or back moved the prompt without moving anything that stands in for
11
+ * it. One renderer for the edge, one component for the frame, and the app
12
+ * points both at the same border style.
13
+ */
14
+ import { Container } from "../tui.js";
15
+ /**
16
+ * `box` rules all four sides; `rule` is the bare pair of horizontals; `none`
17
+ * draws no border at all — for a component nested inside a frame that is
18
+ * already ruling it, where a second rule is just noise.
19
+ */
20
+ export type FrameBorderStyle = "rule" | "box" | "none";
21
+ export interface FrameBorderChars {
22
+ horizontal: string;
23
+ vertical: string;
24
+ topLeft: string;
25
+ topRight: string;
26
+ bottomLeft: string;
27
+ bottomRight: string;
28
+ }
29
+ export declare const DEFAULT_FRAME_BORDER_CHARS: FrameBorderChars;
30
+ /**
31
+ * A label laid into a frame's top border, flush right. `plain` drives the
32
+ * width math; `styled` is what is emitted and must occupy exactly
33
+ * `visibleWidth(plain)` cells — the same plain/styled discipline the footer uses.
34
+ */
35
+ export interface FrameLabel {
36
+ plain: string;
37
+ styled: string;
38
+ }
39
+ /** Border cells kept between the label and the top-right corner, so it reads as inset rather than jammed. */
40
+ export declare const LABEL_RIGHT_INSET = 2;
41
+ /**
42
+ * Border cells required to the *left* of the label. Below this the label is
43
+ * dropped rather than drawn: a label butting straight up against the scroll
44
+ * indicator reads as one run-on string, and a border with no run of border in it
45
+ * has stopped looking like a border.
46
+ */
47
+ export declare const MIN_LABEL_LEAD_IN = 4;
48
+ export interface FrameEdgeOptions {
49
+ edge: "top" | "bottom";
50
+ /** Cells between the corners in box mode; the whole width in rule mode. */
51
+ barWidth: number;
52
+ box: boolean;
53
+ chars: FrameBorderChars;
54
+ color: (str: string) => string;
55
+ /** Rides the top border only, and yields to the scroll indicator. */
56
+ label?: FrameLabel;
57
+ /** Rows hidden past this edge, announced as `─── ↑ N more `. */
58
+ hidden?: number;
59
+ }
60
+ /**
61
+ * One horizontal edge of a frame: the rule, the scroll indicator that eats into
62
+ * it, the label that rides what is left, and the corners in box mode.
63
+ */
64
+ export declare function renderFrameEdge(options: FrameEdgeOptions): string;
65
+ export interface FrameOptions {
66
+ border?: FrameBorderStyle;
67
+ /** Columns of gutter inside the side borders. Defaults to 1, as the prompt's does. */
68
+ paddingX?: number;
69
+ borderChars?: Partial<FrameBorderChars>;
70
+ color?: (str: string) => string;
71
+ }
72
+ /**
73
+ * A `Container` that frames its children.
74
+ *
75
+ * Children are rendered at the width left inside the border and the gutter, and
76
+ * every row is padded out to it, so a row that styles a whole band (a selected
77
+ * list row, say) fills the frame rather than the terminal. A frame with nothing
78
+ * in it draws nothing at all — the same contract `Box` keeps.
79
+ */
80
+ export declare class Frame extends Container {
81
+ borderColor: (str: string) => string;
82
+ /** Laid into the top border, flush right. Undefined draws a plain edge. */
83
+ label?: FrameLabel;
84
+ private border;
85
+ private paddingX;
86
+ private borderChars;
87
+ private memo?;
88
+ constructor(options?: FrameOptions);
89
+ getBorder(): FrameBorderStyle;
90
+ setBorder(border: FrameBorderStyle): void;
91
+ setPaddingX(paddingX: number): void;
92
+ /**
93
+ * Lay a label into the top border, or clear it.
94
+ *
95
+ * Compared before it is taken, not just assigned: an owner that resolves its
96
+ * label per frame (as `InputFrame` does, so a theme switch repaints it) calls
97
+ * this on every render, and dropping the memo each time would hand the TUI
98
+ * root a fresh array every frame — which is exactly the reference stability
99
+ * the renderer diffs whole regions by.
100
+ */
101
+ setLabel(label: FrameLabel | undefined): void;
102
+ invalidate(): void;
103
+ /** The width children are handed at this frame width. */
104
+ contentWidth(width: number): number;
105
+ /**
106
+ * Whether a label of this plain text would actually ride the top border at
107
+ * this width, rather than being dropped for want of a run of border beside
108
+ * it. The owner asks so it can put the text somewhere else instead of
109
+ * losing it — see `InputFrame`.
110
+ */
111
+ labelFits(plain: string, width: number): boolean;
112
+ /** Box mode needs two columns for the sides plus one of content; below that it rules. */
113
+ private isBox;
114
+ private resolvePaddingX;
115
+ render(width: number): string[];
116
+ }
117
+ //# sourceMappingURL=frame.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frame.d.ts","sourceRoot":"","sources":["../../src/components/frame.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAGtC;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;AAEvD,MAAM,WAAW,gBAAgB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,eAAO,MAAM,0BAA0B,EAAE,gBAOxC,CAAC;AAEF;;;;GAIG;AACH,MAAM,WAAW,UAAU;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CACf;AAED,6GAA6G;AAC7G,eAAO,MAAM,iBAAiB,IAAI,CAAC;AAEnC;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,IAAI,CAAC;AAEnC,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,KAAK,GAAG,QAAQ,CAAC;IACvB,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,OAAO,CAAC;IACb,KAAK,EAAE,gBAAgB,CAAC;IACxB,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/B,qEAAqE;IACrE,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,wEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,gBAAgB,GAAG,MAAM,CAoCjE;AAED,MAAM,WAAW,YAAY;IAC5B,MAAM,CAAC,EAAE,gBAAgB,CAAC;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACxC,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;CAChC;AAED;;;;;;;GAOG;AACH,qBAAa,KAAM,SAAQ,SAAS;IAC5B,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5C,2EAA2E;IACpE,KAAK,CAAC,EAAE,UAAU,CAAC;IAC1B,OAAO,CAAC,MAAM,CAAmB;IACjC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,WAAW,CAAmB;IACtC,OAAO,CAAC,IAAI,CAAC,CAAwF;IAErG,YAAY,OAAO,GAAE,YAAiB,EAMrC;IAED,SAAS,IAAI,gBAAgB,CAE5B;IAED,SAAS,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,CAIxC;IAED,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAIlC;IAED;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,SAAS,GAAG,IAAI,CAI5C;IAEQ,UAAU,IAAI,IAAI,CAG1B;IAED,yDAAyD;IACzD,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKlC;IAED;;;;;OAKG;IACH,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAI/C;IAED,yFAAyF;IACzF,OAAO,CAAC,KAAK;IAIb,OAAO,CAAC,eAAe;IAId,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAkDvC;CACD","sourcesContent":["/**\n * The frame the prompt box draws, and the one every surface that takes its\n * place draws with it.\n *\n * The editor owned this border alone for a long time, and every other surface\n * that replaces the editor — the pickers, the options pane, the extension\n * input and editor, the login dialog — drew two bare rules instead. Two rules\n * is not the same shape as a box: the content sat flush at column 0 where the\n * editor insets it, nothing named the surface, and switching the editor to\n * `rule` or back moved the prompt without moving anything that stands in for\n * it. One renderer for the edge, one component for the frame, and the app\n * points both at the same border style.\n */\n\nimport { Container } from \"../tui.js\";\nimport { truncateToWidth, visibleWidth } from \"../utils.js\";\n\n/**\n * `box` rules all four sides; `rule` is the bare pair of horizontals; `none`\n * draws no border at all — for a component nested inside a frame that is\n * already ruling it, where a second rule is just noise.\n */\nexport type FrameBorderStyle = \"rule\" | \"box\" | \"none\";\n\nexport interface FrameBorderChars {\n\thorizontal: string;\n\tvertical: string;\n\ttopLeft: string;\n\ttopRight: string;\n\tbottomLeft: string;\n\tbottomRight: string;\n}\n\nexport const DEFAULT_FRAME_BORDER_CHARS: FrameBorderChars = {\n\thorizontal: \"─\",\n\tvertical: \"│\",\n\ttopLeft: \"┌\",\n\ttopRight: \"┐\",\n\tbottomLeft: \"└\",\n\tbottomRight: \"┘\",\n};\n\n/**\n * A label laid into a frame's top border, flush right. `plain` drives the\n * width math; `styled` is what is emitted and must occupy exactly\n * `visibleWidth(plain)` cells — the same plain/styled discipline the footer uses.\n */\nexport interface FrameLabel {\n\tplain: string;\n\tstyled: string;\n}\n\n/** Border cells kept between the label and the top-right corner, so it reads as inset rather than jammed. */\nexport const LABEL_RIGHT_INSET = 2;\n\n/**\n * Border cells required to the *left* of the label. Below this the label is\n * dropped rather than drawn: a label butting straight up against the scroll\n * indicator reads as one run-on string, and a border with no run of border in it\n * has stopped looking like a border.\n */\nexport const MIN_LABEL_LEAD_IN = 4;\n\nexport interface FrameEdgeOptions {\n\tedge: \"top\" | \"bottom\";\n\t/** Cells between the corners in box mode; the whole width in rule mode. */\n\tbarWidth: number;\n\tbox: boolean;\n\tchars: FrameBorderChars;\n\tcolor: (str: string) => string;\n\t/** Rides the top border only, and yields to the scroll indicator. */\n\tlabel?: FrameLabel;\n\t/** Rows hidden past this edge, announced as `─── ↑ N more `. */\n\thidden?: number;\n}\n\n/**\n * One horizontal edge of a frame: the rule, the scroll indicator that eats into\n * it, the label that rides what is left, and the corners in box mode.\n */\nexport function renderFrameEdge(options: FrameEdgeOptions): string {\n\tconst { edge, barWidth, box, chars, color } = options;\n\tconst hidden = options.hidden ?? 0;\n\tlet indicator = \"\";\n\tif (hidden > 0) {\n\t\tconst arrow = edge === \"top\" ? \"↑\" : \"↓\";\n\t\tindicator = `${chars.horizontal.repeat(3)} ${arrow} ${hidden} more `;\n\t}\n\tconst indicatorWidth = visibleWidth(indicator);\n\n\t// The label rides on the top border only, and yields to the scroll\n\t// indicator: the indicator says the content is longer than the frame, which\n\t// the reader needs *now*, while the label only says which surface this is.\n\tconst label = edge === \"top\" ? options.label : undefined;\n\tconst leadIn = label ? barWidth - indicatorWidth - visibleWidth(label.plain) - LABEL_RIGHT_INSET : -1;\n\n\tlet bar: string;\n\tif (label && leadIn >= MIN_LABEL_LEAD_IN) {\n\t\tbar =\n\t\t\tcolor(indicator + chars.horizontal.repeat(leadIn)) +\n\t\t\tlabel.styled +\n\t\t\tcolor(chars.horizontal.repeat(LABEL_RIGHT_INSET));\n\t} else if (indicatorWidth > 0) {\n\t\tconst remaining = barWidth - indicatorWidth;\n\t\tbar = color(\n\t\t\tremaining >= 0 ? indicator + chars.horizontal.repeat(remaining) : truncateToWidth(indicator, barWidth),\n\t\t);\n\t} else {\n\t\tbar = color(chars.horizontal.repeat(barWidth));\n\t}\n\tif (!box) return bar;\n\t// Corners must stay aligned, so pad back any width lost to truncation.\n\tbar += color(chars.horizontal.repeat(Math.max(0, barWidth - visibleWidth(bar))));\n\tconst left = edge === \"top\" ? chars.topLeft : chars.bottomLeft;\n\tconst right = edge === \"top\" ? chars.topRight : chars.bottomRight;\n\treturn color(left) + bar + color(right);\n}\n\nexport interface FrameOptions {\n\tborder?: FrameBorderStyle;\n\t/** Columns of gutter inside the side borders. Defaults to 1, as the prompt's does. */\n\tpaddingX?: number;\n\tborderChars?: Partial<FrameBorderChars>;\n\tcolor?: (str: string) => string;\n}\n\n/**\n * A `Container` that frames its children.\n *\n * Children are rendered at the width left inside the border and the gutter, and\n * every row is padded out to it, so a row that styles a whole band (a selected\n * list row, say) fills the frame rather than the terminal. A frame with nothing\n * in it draws nothing at all — the same contract `Box` keeps.\n */\nexport class Frame extends Container {\n\tpublic borderColor: (str: string) => string;\n\t/** Laid into the top border, flush right. Undefined draws a plain edge. */\n\tpublic label?: FrameLabel;\n\tprivate border: FrameBorderStyle;\n\tprivate paddingX: number;\n\tprivate borderChars: FrameBorderChars;\n\tprivate memo?: { width: number; childLines: string[]; top: string; bottom: string; lines: string[] };\n\n\tconstructor(options: FrameOptions = {}) {\n\t\tsuper();\n\t\tthis.border = options.border ?? \"box\";\n\t\tthis.paddingX = options.paddingX ?? 1;\n\t\tthis.borderChars = { ...DEFAULT_FRAME_BORDER_CHARS, ...options.borderChars };\n\t\tthis.borderColor = options.color ?? ((str: string) => str);\n\t}\n\n\tgetBorder(): FrameBorderStyle {\n\t\treturn this.border;\n\t}\n\n\tsetBorder(border: FrameBorderStyle): void {\n\t\tif (this.border === border) return;\n\t\tthis.border = border;\n\t\tthis.invalidate();\n\t}\n\n\tsetPaddingX(paddingX: number): void {\n\t\tif (this.paddingX === paddingX) return;\n\t\tthis.paddingX = paddingX;\n\t\tthis.invalidate();\n\t}\n\n\t/**\n\t * Lay a label into the top border, or clear it.\n\t *\n\t * Compared before it is taken, not just assigned: an owner that resolves its\n\t * label per frame (as `InputFrame` does, so a theme switch repaints it) calls\n\t * this on every render, and dropping the memo each time would hand the TUI\n\t * root a fresh array every frame — which is exactly the reference stability\n\t * the renderer diffs whole regions by.\n\t */\n\tsetLabel(label: FrameLabel | undefined): void {\n\t\tif (this.label?.plain === label?.plain && this.label?.styled === label?.styled) return;\n\t\tthis.label = label;\n\t\tthis.memo = undefined;\n\t}\n\n\toverride invalidate(): void {\n\t\tthis.memo = undefined;\n\t\tsuper.invalidate();\n\t}\n\n\t/** The width children are handed at this frame width. */\n\tcontentWidth(width: number): number {\n\t\tif (this.border === \"none\") return Math.max(1, width);\n\t\tconst box = this.isBox(width);\n\t\tconst innerWidth = Math.max(1, width - (box ? 2 : 0));\n\t\treturn Math.max(1, innerWidth - this.resolvePaddingX(innerWidth) * 2);\n\t}\n\n\t/**\n\t * Whether a label of this plain text would actually ride the top border at\n\t * this width, rather than being dropped for want of a run of border beside\n\t * it. The owner asks so it can put the text somewhere else instead of\n\t * losing it — see `InputFrame`.\n\t */\n\tlabelFits(plain: string, width: number): boolean {\n\t\tif (this.border === \"none\" || plain === \"\") return false;\n\t\tconst innerWidth = Math.max(1, width - (this.isBox(width) ? 2 : 0));\n\t\treturn innerWidth - visibleWidth(plain) - LABEL_RIGHT_INSET >= MIN_LABEL_LEAD_IN;\n\t}\n\n\t/** Box mode needs two columns for the sides plus one of content; below that it rules. */\n\tprivate isBox(width: number): boolean {\n\t\treturn this.border === \"box\" && width >= 4;\n\t}\n\n\tprivate resolvePaddingX(innerWidth: number): number {\n\t\treturn Math.min(this.paddingX, Math.max(0, Math.floor((innerWidth - 1) / 2)));\n\t}\n\n\toverride render(width: number): string[] {\n\t\t// No border means no gutter either: the children get the whole width,\n\t\t// exactly as a plain Container would hand it to them.\n\t\tif (this.border === \"none\") return super.render(width);\n\t\tconst box = this.isBox(width);\n\t\tconst innerWidth = Math.max(1, width - (box ? 2 : 0));\n\t\tconst paddingX = this.resolvePaddingX(innerWidth);\n\t\tconst contentWidth = Math.max(1, innerWidth - paddingX * 2);\n\n\t\tconst childLines = super.render(contentWidth);\n\t\tif (childLines.length === 0) return [];\n\n\t\tconst edge = (which: \"top\" | \"bottom\") =>\n\t\t\trenderFrameEdge({\n\t\t\t\tedge: which,\n\t\t\t\tbarWidth: innerWidth,\n\t\t\t\tbox,\n\t\t\t\tchars: this.borderChars,\n\t\t\t\tcolor: this.borderColor,\n\t\t\t\tlabel: this.label,\n\t\t\t});\n\t\tconst top = edge(\"top\");\n\t\tconst bottom = edge(\"bottom\");\n\n\t\tconst memo = this.memo;\n\t\tif (\n\t\t\tmemo &&\n\t\t\tmemo.width === width &&\n\t\t\tmemo.childLines === childLines &&\n\t\t\tmemo.top === top &&\n\t\t\tmemo.bottom === bottom\n\t\t) {\n\t\t\treturn memo.lines;\n\t\t}\n\n\t\tconst vertical = box ? this.borderColor(this.borderChars.vertical) : \"\";\n\t\tconst gutter = \" \".repeat(paddingX);\n\t\tconst lines: string[] = [top];\n\t\tfor (const line of childLines) {\n\t\t\t// A child that overruns its width would push the right border onto the\n\t\t\t// next row and wrap the whole frame. The frame's geometry is not the\n\t\t\t// child's to break, so it is cut here rather than trusted.\n\t\t\tconst body = visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth) : line;\n\t\t\tconst fill = \" \".repeat(Math.max(0, contentWidth - visibleWidth(body)));\n\t\t\tlines.push(`${vertical}${gutter}${body}${fill}${gutter}${vertical}`);\n\t\t}\n\t\tlines.push(bottom);\n\n\t\tthis.memo = { width, childLines, top, bottom, lines };\n\t\treturn lines;\n\t}\n}\n"]}
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The frame the prompt box draws, and the one every surface that takes its
3
+ * place draws with it.
4
+ *
5
+ * The editor owned this border alone for a long time, and every other surface
6
+ * that replaces the editor — the pickers, the options pane, the extension
7
+ * input and editor, the login dialog — drew two bare rules instead. Two rules
8
+ * is not the same shape as a box: the content sat flush at column 0 where the
9
+ * editor insets it, nothing named the surface, and switching the editor to
10
+ * `rule` or back moved the prompt without moving anything that stands in for
11
+ * it. One renderer for the edge, one component for the frame, and the app
12
+ * points both at the same border style.
13
+ */
14
+ import { Container } from "../tui.js";
15
+ import { truncateToWidth, visibleWidth } from "../utils.js";
16
+ export const DEFAULT_FRAME_BORDER_CHARS = {
17
+ horizontal: "─",
18
+ vertical: "│",
19
+ topLeft: "┌",
20
+ topRight: "┐",
21
+ bottomLeft: "└",
22
+ bottomRight: "┘",
23
+ };
24
+ /** Border cells kept between the label and the top-right corner, so it reads as inset rather than jammed. */
25
+ export const LABEL_RIGHT_INSET = 2;
26
+ /**
27
+ * Border cells required to the *left* of the label. Below this the label is
28
+ * dropped rather than drawn: a label butting straight up against the scroll
29
+ * indicator reads as one run-on string, and a border with no run of border in it
30
+ * has stopped looking like a border.
31
+ */
32
+ export const MIN_LABEL_LEAD_IN = 4;
33
+ /**
34
+ * One horizontal edge of a frame: the rule, the scroll indicator that eats into
35
+ * it, the label that rides what is left, and the corners in box mode.
36
+ */
37
+ export function renderFrameEdge(options) {
38
+ const { edge, barWidth, box, chars, color } = options;
39
+ const hidden = options.hidden ?? 0;
40
+ let indicator = "";
41
+ if (hidden > 0) {
42
+ const arrow = edge === "top" ? "↑" : "↓";
43
+ indicator = `${chars.horizontal.repeat(3)} ${arrow} ${hidden} more `;
44
+ }
45
+ const indicatorWidth = visibleWidth(indicator);
46
+ // The label rides on the top border only, and yields to the scroll
47
+ // indicator: the indicator says the content is longer than the frame, which
48
+ // the reader needs *now*, while the label only says which surface this is.
49
+ const label = edge === "top" ? options.label : undefined;
50
+ const leadIn = label ? barWidth - indicatorWidth - visibleWidth(label.plain) - LABEL_RIGHT_INSET : -1;
51
+ let bar;
52
+ if (label && leadIn >= MIN_LABEL_LEAD_IN) {
53
+ bar =
54
+ color(indicator + chars.horizontal.repeat(leadIn)) +
55
+ label.styled +
56
+ color(chars.horizontal.repeat(LABEL_RIGHT_INSET));
57
+ }
58
+ else if (indicatorWidth > 0) {
59
+ const remaining = barWidth - indicatorWidth;
60
+ bar = color(remaining >= 0 ? indicator + chars.horizontal.repeat(remaining) : truncateToWidth(indicator, barWidth));
61
+ }
62
+ else {
63
+ bar = color(chars.horizontal.repeat(barWidth));
64
+ }
65
+ if (!box)
66
+ return bar;
67
+ // Corners must stay aligned, so pad back any width lost to truncation.
68
+ bar += color(chars.horizontal.repeat(Math.max(0, barWidth - visibleWidth(bar))));
69
+ const left = edge === "top" ? chars.topLeft : chars.bottomLeft;
70
+ const right = edge === "top" ? chars.topRight : chars.bottomRight;
71
+ return color(left) + bar + color(right);
72
+ }
73
+ /**
74
+ * A `Container` that frames its children.
75
+ *
76
+ * Children are rendered at the width left inside the border and the gutter, and
77
+ * every row is padded out to it, so a row that styles a whole band (a selected
78
+ * list row, say) fills the frame rather than the terminal. A frame with nothing
79
+ * in it draws nothing at all — the same contract `Box` keeps.
80
+ */
81
+ export class Frame extends Container {
82
+ borderColor;
83
+ /** Laid into the top border, flush right. Undefined draws a plain edge. */
84
+ label;
85
+ border;
86
+ paddingX;
87
+ borderChars;
88
+ memo;
89
+ constructor(options = {}) {
90
+ super();
91
+ this.border = options.border ?? "box";
92
+ this.paddingX = options.paddingX ?? 1;
93
+ this.borderChars = { ...DEFAULT_FRAME_BORDER_CHARS, ...options.borderChars };
94
+ this.borderColor = options.color ?? ((str) => str);
95
+ }
96
+ getBorder() {
97
+ return this.border;
98
+ }
99
+ setBorder(border) {
100
+ if (this.border === border)
101
+ return;
102
+ this.border = border;
103
+ this.invalidate();
104
+ }
105
+ setPaddingX(paddingX) {
106
+ if (this.paddingX === paddingX)
107
+ return;
108
+ this.paddingX = paddingX;
109
+ this.invalidate();
110
+ }
111
+ /**
112
+ * Lay a label into the top border, or clear it.
113
+ *
114
+ * Compared before it is taken, not just assigned: an owner that resolves its
115
+ * label per frame (as `InputFrame` does, so a theme switch repaints it) calls
116
+ * this on every render, and dropping the memo each time would hand the TUI
117
+ * root a fresh array every frame — which is exactly the reference stability
118
+ * the renderer diffs whole regions by.
119
+ */
120
+ setLabel(label) {
121
+ if (this.label?.plain === label?.plain && this.label?.styled === label?.styled)
122
+ return;
123
+ this.label = label;
124
+ this.memo = undefined;
125
+ }
126
+ invalidate() {
127
+ this.memo = undefined;
128
+ super.invalidate();
129
+ }
130
+ /** The width children are handed at this frame width. */
131
+ contentWidth(width) {
132
+ if (this.border === "none")
133
+ return Math.max(1, width);
134
+ const box = this.isBox(width);
135
+ const innerWidth = Math.max(1, width - (box ? 2 : 0));
136
+ return Math.max(1, innerWidth - this.resolvePaddingX(innerWidth) * 2);
137
+ }
138
+ /**
139
+ * Whether a label of this plain text would actually ride the top border at
140
+ * this width, rather than being dropped for want of a run of border beside
141
+ * it. The owner asks so it can put the text somewhere else instead of
142
+ * losing it — see `InputFrame`.
143
+ */
144
+ labelFits(plain, width) {
145
+ if (this.border === "none" || plain === "")
146
+ return false;
147
+ const innerWidth = Math.max(1, width - (this.isBox(width) ? 2 : 0));
148
+ return innerWidth - visibleWidth(plain) - LABEL_RIGHT_INSET >= MIN_LABEL_LEAD_IN;
149
+ }
150
+ /** Box mode needs two columns for the sides plus one of content; below that it rules. */
151
+ isBox(width) {
152
+ return this.border === "box" && width >= 4;
153
+ }
154
+ resolvePaddingX(innerWidth) {
155
+ return Math.min(this.paddingX, Math.max(0, Math.floor((innerWidth - 1) / 2)));
156
+ }
157
+ render(width) {
158
+ // No border means no gutter either: the children get the whole width,
159
+ // exactly as a plain Container would hand it to them.
160
+ if (this.border === "none")
161
+ return super.render(width);
162
+ const box = this.isBox(width);
163
+ const innerWidth = Math.max(1, width - (box ? 2 : 0));
164
+ const paddingX = this.resolvePaddingX(innerWidth);
165
+ const contentWidth = Math.max(1, innerWidth - paddingX * 2);
166
+ const childLines = super.render(contentWidth);
167
+ if (childLines.length === 0)
168
+ return [];
169
+ const edge = (which) => renderFrameEdge({
170
+ edge: which,
171
+ barWidth: innerWidth,
172
+ box,
173
+ chars: this.borderChars,
174
+ color: this.borderColor,
175
+ label: this.label,
176
+ });
177
+ const top = edge("top");
178
+ const bottom = edge("bottom");
179
+ const memo = this.memo;
180
+ if (memo &&
181
+ memo.width === width &&
182
+ memo.childLines === childLines &&
183
+ memo.top === top &&
184
+ memo.bottom === bottom) {
185
+ return memo.lines;
186
+ }
187
+ const vertical = box ? this.borderColor(this.borderChars.vertical) : "";
188
+ const gutter = " ".repeat(paddingX);
189
+ const lines = [top];
190
+ for (const line of childLines) {
191
+ // A child that overruns its width would push the right border onto the
192
+ // next row and wrap the whole frame. The frame's geometry is not the
193
+ // child's to break, so it is cut here rather than trusted.
194
+ const body = visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth) : line;
195
+ const fill = " ".repeat(Math.max(0, contentWidth - visibleWidth(body)));
196
+ lines.push(`${vertical}${gutter}${body}${fill}${gutter}${vertical}`);
197
+ }
198
+ lines.push(bottom);
199
+ this.memo = { width, childLines, top, bottom, lines };
200
+ return lines;
201
+ }
202
+ }
203
+ //# sourceMappingURL=frame.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frame.js","sourceRoot":"","sources":["../../src/components/frame.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The frame the prompt box draws, and the one every surface that takes its\n * place draws with it.\n *\n * The editor owned this border alone for a long time, and every other surface\n * that replaces the editor — the pickers, the options pane, the extension\n * input and editor, the login dialog — drew two bare rules instead. Two rules\n * is not the same shape as a box: the content sat flush at column 0 where the\n * editor insets it, nothing named the surface, and switching the editor to\n * `rule` or back moved the prompt without moving anything that stands in for\n * it. One renderer for the edge, one component for the frame, and the app\n * points both at the same border style.\n */\n\nimport { Container } from \"../tui.js\";\nimport { truncateToWidth, visibleWidth } from \"../utils.js\";\n\n/**\n * `box` rules all four sides; `rule` is the bare pair of horizontals; `none`\n * draws no border at all — for a component nested inside a frame that is\n * already ruling it, where a second rule is just noise.\n */\nexport type FrameBorderStyle = \"rule\" | \"box\" | \"none\";\n\nexport interface FrameBorderChars {\n\thorizontal: string;\n\tvertical: string;\n\ttopLeft: string;\n\ttopRight: string;\n\tbottomLeft: string;\n\tbottomRight: string;\n}\n\nexport const DEFAULT_FRAME_BORDER_CHARS: FrameBorderChars = {\n\thorizontal: \"─\",\n\tvertical: \"│\",\n\ttopLeft: \"┌\",\n\ttopRight: \"┐\",\n\tbottomLeft: \"└\",\n\tbottomRight: \"┘\",\n};\n\n/**\n * A label laid into a frame's top border, flush right. `plain` drives the\n * width math; `styled` is what is emitted and must occupy exactly\n * `visibleWidth(plain)` cells — the same plain/styled discipline the footer uses.\n */\nexport interface FrameLabel {\n\tplain: string;\n\tstyled: string;\n}\n\n/** Border cells kept between the label and the top-right corner, so it reads as inset rather than jammed. */\nexport const LABEL_RIGHT_INSET = 2;\n\n/**\n * Border cells required to the *left* of the label. Below this the label is\n * dropped rather than drawn: a label butting straight up against the scroll\n * indicator reads as one run-on string, and a border with no run of border in it\n * has stopped looking like a border.\n */\nexport const MIN_LABEL_LEAD_IN = 4;\n\nexport interface FrameEdgeOptions {\n\tedge: \"top\" | \"bottom\";\n\t/** Cells between the corners in box mode; the whole width in rule mode. */\n\tbarWidth: number;\n\tbox: boolean;\n\tchars: FrameBorderChars;\n\tcolor: (str: string) => string;\n\t/** Rides the top border only, and yields to the scroll indicator. */\n\tlabel?: FrameLabel;\n\t/** Rows hidden past this edge, announced as `─── ↑ N more `. */\n\thidden?: number;\n}\n\n/**\n * One horizontal edge of a frame: the rule, the scroll indicator that eats into\n * it, the label that rides what is left, and the corners in box mode.\n */\nexport function renderFrameEdge(options: FrameEdgeOptions): string {\n\tconst { edge, barWidth, box, chars, color } = options;\n\tconst hidden = options.hidden ?? 0;\n\tlet indicator = \"\";\n\tif (hidden > 0) {\n\t\tconst arrow = edge === \"top\" ? \"↑\" : \"↓\";\n\t\tindicator = `${chars.horizontal.repeat(3)} ${arrow} ${hidden} more `;\n\t}\n\tconst indicatorWidth = visibleWidth(indicator);\n\n\t// The label rides on the top border only, and yields to the scroll\n\t// indicator: the indicator says the content is longer than the frame, which\n\t// the reader needs *now*, while the label only says which surface this is.\n\tconst label = edge === \"top\" ? options.label : undefined;\n\tconst leadIn = label ? barWidth - indicatorWidth - visibleWidth(label.plain) - LABEL_RIGHT_INSET : -1;\n\n\tlet bar: string;\n\tif (label && leadIn >= MIN_LABEL_LEAD_IN) {\n\t\tbar =\n\t\t\tcolor(indicator + chars.horizontal.repeat(leadIn)) +\n\t\t\tlabel.styled +\n\t\t\tcolor(chars.horizontal.repeat(LABEL_RIGHT_INSET));\n\t} else if (indicatorWidth > 0) {\n\t\tconst remaining = barWidth - indicatorWidth;\n\t\tbar = color(\n\t\t\tremaining >= 0 ? indicator + chars.horizontal.repeat(remaining) : truncateToWidth(indicator, barWidth),\n\t\t);\n\t} else {\n\t\tbar = color(chars.horizontal.repeat(barWidth));\n\t}\n\tif (!box) return bar;\n\t// Corners must stay aligned, so pad back any width lost to truncation.\n\tbar += color(chars.horizontal.repeat(Math.max(0, barWidth - visibleWidth(bar))));\n\tconst left = edge === \"top\" ? chars.topLeft : chars.bottomLeft;\n\tconst right = edge === \"top\" ? chars.topRight : chars.bottomRight;\n\treturn color(left) + bar + color(right);\n}\n\nexport interface FrameOptions {\n\tborder?: FrameBorderStyle;\n\t/** Columns of gutter inside the side borders. Defaults to 1, as the prompt's does. */\n\tpaddingX?: number;\n\tborderChars?: Partial<FrameBorderChars>;\n\tcolor?: (str: string) => string;\n}\n\n/**\n * A `Container` that frames its children.\n *\n * Children are rendered at the width left inside the border and the gutter, and\n * every row is padded out to it, so a row that styles a whole band (a selected\n * list row, say) fills the frame rather than the terminal. A frame with nothing\n * in it draws nothing at all — the same contract `Box` keeps.\n */\nexport class Frame extends Container {\n\tpublic borderColor: (str: string) => string;\n\t/** Laid into the top border, flush right. Undefined draws a plain edge. */\n\tpublic label?: FrameLabel;\n\tprivate border: FrameBorderStyle;\n\tprivate paddingX: number;\n\tprivate borderChars: FrameBorderChars;\n\tprivate memo?: { width: number; childLines: string[]; top: string; bottom: string; lines: string[] };\n\n\tconstructor(options: FrameOptions = {}) {\n\t\tsuper();\n\t\tthis.border = options.border ?? \"box\";\n\t\tthis.paddingX = options.paddingX ?? 1;\n\t\tthis.borderChars = { ...DEFAULT_FRAME_BORDER_CHARS, ...options.borderChars };\n\t\tthis.borderColor = options.color ?? ((str: string) => str);\n\t}\n\n\tgetBorder(): FrameBorderStyle {\n\t\treturn this.border;\n\t}\n\n\tsetBorder(border: FrameBorderStyle): void {\n\t\tif (this.border === border) return;\n\t\tthis.border = border;\n\t\tthis.invalidate();\n\t}\n\n\tsetPaddingX(paddingX: number): void {\n\t\tif (this.paddingX === paddingX) return;\n\t\tthis.paddingX = paddingX;\n\t\tthis.invalidate();\n\t}\n\n\t/**\n\t * Lay a label into the top border, or clear it.\n\t *\n\t * Compared before it is taken, not just assigned: an owner that resolves its\n\t * label per frame (as `InputFrame` does, so a theme switch repaints it) calls\n\t * this on every render, and dropping the memo each time would hand the TUI\n\t * root a fresh array every frame — which is exactly the reference stability\n\t * the renderer diffs whole regions by.\n\t */\n\tsetLabel(label: FrameLabel | undefined): void {\n\t\tif (this.label?.plain === label?.plain && this.label?.styled === label?.styled) return;\n\t\tthis.label = label;\n\t\tthis.memo = undefined;\n\t}\n\n\toverride invalidate(): void {\n\t\tthis.memo = undefined;\n\t\tsuper.invalidate();\n\t}\n\n\t/** The width children are handed at this frame width. */\n\tcontentWidth(width: number): number {\n\t\tif (this.border === \"none\") return Math.max(1, width);\n\t\tconst box = this.isBox(width);\n\t\tconst innerWidth = Math.max(1, width - (box ? 2 : 0));\n\t\treturn Math.max(1, innerWidth - this.resolvePaddingX(innerWidth) * 2);\n\t}\n\n\t/**\n\t * Whether a label of this plain text would actually ride the top border at\n\t * this width, rather than being dropped for want of a run of border beside\n\t * it. The owner asks so it can put the text somewhere else instead of\n\t * losing it — see `InputFrame`.\n\t */\n\tlabelFits(plain: string, width: number): boolean {\n\t\tif (this.border === \"none\" || plain === \"\") return false;\n\t\tconst innerWidth = Math.max(1, width - (this.isBox(width) ? 2 : 0));\n\t\treturn innerWidth - visibleWidth(plain) - LABEL_RIGHT_INSET >= MIN_LABEL_LEAD_IN;\n\t}\n\n\t/** Box mode needs two columns for the sides plus one of content; below that it rules. */\n\tprivate isBox(width: number): boolean {\n\t\treturn this.border === \"box\" && width >= 4;\n\t}\n\n\tprivate resolvePaddingX(innerWidth: number): number {\n\t\treturn Math.min(this.paddingX, Math.max(0, Math.floor((innerWidth - 1) / 2)));\n\t}\n\n\toverride render(width: number): string[] {\n\t\t// No border means no gutter either: the children get the whole width,\n\t\t// exactly as a plain Container would hand it to them.\n\t\tif (this.border === \"none\") return super.render(width);\n\t\tconst box = this.isBox(width);\n\t\tconst innerWidth = Math.max(1, width - (box ? 2 : 0));\n\t\tconst paddingX = this.resolvePaddingX(innerWidth);\n\t\tconst contentWidth = Math.max(1, innerWidth - paddingX * 2);\n\n\t\tconst childLines = super.render(contentWidth);\n\t\tif (childLines.length === 0) return [];\n\n\t\tconst edge = (which: \"top\" | \"bottom\") =>\n\t\t\trenderFrameEdge({\n\t\t\t\tedge: which,\n\t\t\t\tbarWidth: innerWidth,\n\t\t\t\tbox,\n\t\t\t\tchars: this.borderChars,\n\t\t\t\tcolor: this.borderColor,\n\t\t\t\tlabel: this.label,\n\t\t\t});\n\t\tconst top = edge(\"top\");\n\t\tconst bottom = edge(\"bottom\");\n\n\t\tconst memo = this.memo;\n\t\tif (\n\t\t\tmemo &&\n\t\t\tmemo.width === width &&\n\t\t\tmemo.childLines === childLines &&\n\t\t\tmemo.top === top &&\n\t\t\tmemo.bottom === bottom\n\t\t) {\n\t\t\treturn memo.lines;\n\t\t}\n\n\t\tconst vertical = box ? this.borderColor(this.borderChars.vertical) : \"\";\n\t\tconst gutter = \" \".repeat(paddingX);\n\t\tconst lines: string[] = [top];\n\t\tfor (const line of childLines) {\n\t\t\t// A child that overruns its width would push the right border onto the\n\t\t\t// next row and wrap the whole frame. The frame's geometry is not the\n\t\t\t// child's to break, so it is cut here rather than trusted.\n\t\t\tconst body = visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth) : line;\n\t\t\tconst fill = \" \".repeat(Math.max(0, contentWidth - visibleWidth(body)));\n\t\t\tlines.push(`${vertical}${gutter}${body}${fill}${gutter}${vertical}`);\n\t\t}\n\t\tlines.push(bottom);\n\n\t\tthis.memo = { width, childLines, top, bottom, lines };\n\t\treturn lines;\n\t}\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"select-list.d.ts","sourceRoot":"","sources":["../../src/components/select-list.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAG3C,sDAAsD;AACtD,eAAO,MAAM,qBAAqB,YAAO,CAAC;AAS1C,MAAM,WAAW,UAAU;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC/B,cAAc,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACzC,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACvC,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACtC,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACrC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAClC;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CACvC;AAED,MAAM,WAAW,gCAAgC;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,UAAU,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,uBAAuB;IACvC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,gCAAgC,KAAK,MAAM,CAAC;CACxE;AAED,qBAAa,UAAW,YAAW,SAAS;IAC3C,OAAO,CAAC,KAAK,CAAoB;IACjC,OAAO,CAAC,aAAa,CAAoB;IACzC,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,UAAU,CAAa;IAC/B,OAAO,CAAC,KAAK,CAAkB;IAC/B,OAAO,CAAC,MAAM,CAA0B;IAEjC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;IACtC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,iBAAiB,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;IAEtD,YAAY,KAAK,EAAE,UAAU,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,GAAE,uBAA4B,EAMhH;IAED,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAI9B;IAED,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAEpC;IAED,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAoC9B;IAED,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAyBjC;IAED,OAAO,CAAC,UAAU;IA0ClB,sFAAsF;IACtF,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,qBAAqB;IAS7B,OAAO,CAAC,sBAAsB;IAY9B,OAAO,CAAC,eAAe;IAevB,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,qBAAqB;IAO7B,eAAe,IAAI,UAAU,GAAG,IAAI,CAGnC;CACD","sourcesContent":["import { getKeybindings } from \"../keybindings.js\";\nimport type { Component } from \"../tui.js\";\nimport { applyBackgroundToLine, truncateToWidth, visibleWidth } from \"../utils.js\";\n\n/** Used when a theme does not name its own cursor. */\nexport const DEFAULT_SELECT_CURSOR = \"→ \";\n\nconst DEFAULT_PRIMARY_COLUMN_WIDTH = 32;\nconst PRIMARY_COLUMN_GAP = 2;\nconst MIN_DESCRIPTION_WIDTH = 10;\n\nconst normalizeToSingleLine = (text: string): string => text.replace(/[\\r\\n]+/g, \" \").trim();\nconst clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max));\n\nexport interface SelectItem {\n\tvalue: string;\n\tlabel: string;\n\tdescription?: string;\n}\n\nexport interface SelectListTheme {\n\tselectedPrefix: (text: string) => string;\n\tselectedText: (text: string) => string;\n\tdescription: (text: string) => string;\n\tscrollInfo: (text: string) => string;\n\tnoMatch: (text: string) => string;\n\t/**\n\t * Optional marker for the selected row, including any trailing space.\n\t * Defaults to `DEFAULT_SELECT_CURSOR`. Unselected rows are indented by the\n\t * cursor's visible width, so the column stays aligned whatever it is set to.\n\t */\n\tcursor?: string;\n\t/**\n\t * Optional background for the selected row. When set, the row is padded out\n\t * to the full width before being painted, so the highlight reaches the right\n\t * edge instead of stopping at the text. `selectedText` still styles the\n\t * content, so the band is added to that marker rather than replacing it.\n\t * Themes that leave this undefined keep the arrow-and-accent row as-is.\n\t */\n\tselectedRow?: (text: string) => string;\n}\n\nexport interface SelectListTruncatePrimaryContext {\n\ttext: string;\n\tmaxWidth: number;\n\tcolumnWidth: number;\n\titem: SelectItem;\n\tisSelected: boolean;\n}\n\nexport interface SelectListLayoutOptions {\n\tminPrimaryColumnWidth?: number;\n\tmaxPrimaryColumnWidth?: number;\n\ttruncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;\n}\n\nexport class SelectList implements Component {\n\tprivate items: SelectItem[] = [];\n\tprivate filteredItems: SelectItem[] = [];\n\tprivate selectedIndex: number = 0;\n\tprivate maxVisible: number = 5;\n\tprivate theme: SelectListTheme;\n\tprivate layout: SelectListLayoutOptions;\n\n\tpublic onSelect?: (item: SelectItem) => void;\n\tpublic onCancel?: () => void;\n\tpublic onSelectionChange?: (item: SelectItem) => void;\n\n\tconstructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme, layout: SelectListLayoutOptions = {}) {\n\t\tthis.items = items;\n\t\tthis.filteredItems = items;\n\t\tthis.maxVisible = maxVisible;\n\t\tthis.theme = theme;\n\t\tthis.layout = layout;\n\t}\n\n\tsetFilter(filter: string): void {\n\t\tthis.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase()));\n\t\t// Reset selection when filter changes\n\t\tthis.selectedIndex = 0;\n\t}\n\n\tsetSelectedIndex(index: number): void {\n\t\tthis.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));\n\t}\n\n\tinvalidate(): void {\n\t\t// No cached state to invalidate currently\n\t}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\t// If no items match filter, show message\n\t\tif (this.filteredItems.length === 0) {\n\t\t\tlines.push(this.theme.noMatch(\" No matching commands\"));\n\t\t\treturn lines;\n\t\t}\n\n\t\tconst primaryColumnWidth = this.getPrimaryColumnWidth();\n\n\t\t// Calculate visible range with scrolling\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);\n\n\t\t// Render visible items\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst item = this.filteredItems[i];\n\t\t\tif (!item) continue;\n\n\t\t\tconst isSelected = i === this.selectedIndex;\n\t\t\tconst descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined;\n\t\t\tlines.push(this.renderItem(item, isSelected, width, descriptionSingleLine, primaryColumnWidth));\n\t\t}\n\n\t\t// Add scroll indicators if needed\n\t\tif (startIndex > 0 || endIndex < this.filteredItems.length) {\n\t\t\tconst scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`;\n\t\t\t// Truncate if too long for terminal\n\t\t\tlines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, \"\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tconst kb = getKeybindings();\n\t\t// Up arrow - wrap to bottom when at top\n\t\tif (kb.matches(keyData, \"tui.select.up\")) {\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;\n\t\t\tthis.notifySelectionChange();\n\t\t}\n\t\t// Down arrow - wrap to top when at bottom\n\t\telse if (kb.matches(keyData, \"tui.select.down\")) {\n\t\t\tthis.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t\tthis.notifySelectionChange();\n\t\t}\n\t\t// Enter\n\t\telse if (kb.matches(keyData, \"tui.select.confirm\")) {\n\t\t\tconst selectedItem = this.filteredItems[this.selectedIndex];\n\t\t\tif (selectedItem && this.onSelect) {\n\t\t\t\tthis.onSelect(selectedItem);\n\t\t\t}\n\t\t}\n\t\t// Escape or Ctrl+C\n\t\telse if (kb.matches(keyData, \"tui.select.cancel\")) {\n\t\t\tif (this.onCancel) {\n\t\t\t\tthis.onCancel();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate renderItem(\n\t\titem: SelectItem,\n\t\tisSelected: boolean,\n\t\twidth: number,\n\t\tdescriptionSingleLine: string | undefined,\n\t\tprimaryColumnWidth: number,\n\t): string {\n\t\tconst cursor = this.theme.cursor ?? DEFAULT_SELECT_CURSOR;\n\t\tconst prefixWidth = visibleWidth(cursor);\n\t\t// Indent unselected rows by the cursor's width rather than a fixed two\n\t\t// columns, so the value column stays put whatever cursor a theme names.\n\t\tconst prefix = isSelected ? cursor : \" \".repeat(prefixWidth);\n\n\t\tif (descriptionSingleLine && width > 40) {\n\t\t\tconst effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));\n\t\t\tconst maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP);\n\t\t\tconst truncatedValue = this.truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth);\n\t\t\tconst truncatedValueWidth = visibleWidth(truncatedValue);\n\t\t\tconst spacing = \" \".repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth));\n\t\t\tconst descriptionStart = prefixWidth + truncatedValueWidth + spacing.length;\n\t\t\tconst remainingWidth = width - descriptionStart - 2; // -2 for safety\n\n\t\t\tif (remainingWidth > MIN_DESCRIPTION_WIDTH) {\n\t\t\t\tconst truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, \"\");\n\t\t\t\tif (isSelected) {\n\t\t\t\t\treturn this.renderSelected(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`, width);\n\t\t\t\t}\n\n\t\t\t\tconst descText = this.theme.description(spacing + truncatedDesc);\n\t\t\t\treturn prefix + truncatedValue + descText;\n\t\t\t}\n\t\t}\n\n\t\tconst maxWidth = width - prefixWidth - 2;\n\t\tconst truncatedValue = this.truncatePrimary(item, isSelected, maxWidth, maxWidth);\n\t\tif (isSelected) {\n\t\t\treturn this.renderSelected(`${prefix}${truncatedValue}`, width);\n\t\t}\n\n\t\treturn prefix + truncatedValue;\n\t}\n\n\t/** Style the selected row, and fill it to the edge when the theme asks for a band. */\n\tprivate renderSelected(row: string, width: number): string {\n\t\tconst styled = this.theme.selectedText(row);\n\t\tif (!this.theme.selectedRow) {\n\t\t\treturn styled;\n\t\t}\n\t\treturn applyBackgroundToLine(styled, width, this.theme.selectedRow);\n\t}\n\n\tprivate getPrimaryColumnWidth(): number {\n\t\tconst { min, max } = this.getPrimaryColumnBounds();\n\t\tconst widestPrimary = this.filteredItems.reduce((widest, item) => {\n\t\t\treturn Math.max(widest, visibleWidth(this.getDisplayValue(item)) + PRIMARY_COLUMN_GAP);\n\t\t}, 0);\n\n\t\treturn clamp(widestPrimary, min, max);\n\t}\n\n\tprivate getPrimaryColumnBounds(): { min: number; max: number } {\n\t\tconst rawMin =\n\t\t\tthis.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;\n\t\tconst rawMax =\n\t\t\tthis.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;\n\n\t\treturn {\n\t\t\tmin: Math.max(1, Math.min(rawMin, rawMax)),\n\t\t\tmax: Math.max(1, Math.max(rawMin, rawMax)),\n\t\t};\n\t}\n\n\tprivate truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string {\n\t\tconst displayValue = this.getDisplayValue(item);\n\t\tconst truncatedValue = this.layout.truncatePrimary\n\t\t\t? this.layout.truncatePrimary({\n\t\t\t\t\ttext: displayValue,\n\t\t\t\t\tmaxWidth,\n\t\t\t\t\tcolumnWidth,\n\t\t\t\t\titem,\n\t\t\t\t\tisSelected,\n\t\t\t\t})\n\t\t\t: truncateToWidth(displayValue, maxWidth, \"\");\n\n\t\treturn truncateToWidth(truncatedValue, maxWidth, \"\");\n\t}\n\n\tprivate getDisplayValue(item: SelectItem): string {\n\t\treturn item.label || item.value;\n\t}\n\n\tprivate notifySelectionChange(): void {\n\t\tconst selectedItem = this.filteredItems[this.selectedIndex];\n\t\tif (selectedItem && this.onSelectionChange) {\n\t\t\tthis.onSelectionChange(selectedItem);\n\t\t}\n\t}\n\n\tgetSelectedItem(): SelectItem | null {\n\t\tconst item = this.filteredItems[this.selectedIndex];\n\t\treturn item || null;\n\t}\n}\n"]}
1
+ {"version":3,"file":"select-list.d.ts","sourceRoot":"","sources":["../../src/components/select-list.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAG3C,sDAAsD;AACtD,eAAO,MAAM,qBAAqB,YAAO,CAAC;AAS1C,MAAM,WAAW,UAAU;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC/B,cAAc,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACzC,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACvC,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACtC,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACrC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAClC;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CACvC;AAED,MAAM,WAAW,gCAAgC;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,UAAU,CAAC;IACjB,UAAU,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,uBAAuB;IACvC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,gCAAgC,KAAK,MAAM,CAAC;CACxE;AAED,qBAAa,UAAW,YAAW,SAAS;IAC3C,OAAO,CAAC,KAAK,CAAoB;IACjC,OAAO,CAAC,aAAa,CAAoB;IACzC,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,UAAU,CAAa;IAC/B,OAAO,CAAC,KAAK,CAAkB;IAC/B,OAAO,CAAC,MAAM,CAA0B;IAEjC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;IACtC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,iBAAiB,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;IAEtD,YAAY,KAAK,EAAE,UAAU,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,GAAE,uBAA4B,EAMhH;IAED,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAI9B;IAED,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAEpC;IAED,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAoC9B;IAED,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAyBjC;IAED,OAAO,CAAC,UAAU;IAgDlB,sFAAsF;IACtF,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,qBAAqB;IAS7B,OAAO,CAAC,sBAAsB;IAY9B,OAAO,CAAC,eAAe;IAevB,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,qBAAqB;IAO7B,eAAe,IAAI,UAAU,GAAG,IAAI,CAGnC;CACD","sourcesContent":["import { getKeybindings } from \"../keybindings.js\";\nimport type { Component } from \"../tui.js\";\nimport { applyBackgroundToLine, truncateToWidth, visibleWidth } from \"../utils.js\";\n\n/** Used when a theme does not name its own cursor. */\nexport const DEFAULT_SELECT_CURSOR = \"→ \";\n\nconst DEFAULT_PRIMARY_COLUMN_WIDTH = 32;\nconst PRIMARY_COLUMN_GAP = 2;\nconst MIN_DESCRIPTION_WIDTH = 10;\n\nconst normalizeToSingleLine = (text: string): string => text.replace(/[\\r\\n]+/g, \" \").trim();\nconst clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max));\n\nexport interface SelectItem {\n\tvalue: string;\n\tlabel: string;\n\tdescription?: string;\n}\n\nexport interface SelectListTheme {\n\tselectedPrefix: (text: string) => string;\n\tselectedText: (text: string) => string;\n\tdescription: (text: string) => string;\n\tscrollInfo: (text: string) => string;\n\tnoMatch: (text: string) => string;\n\t/**\n\t * Optional marker for the selected row, including any trailing space.\n\t * Defaults to `DEFAULT_SELECT_CURSOR`. Unselected rows are indented by the\n\t * cursor's visible width, so the column stays aligned whatever it is set to.\n\t */\n\tcursor?: string;\n\t/**\n\t * Optional background for the selected row. When set, the row is padded out\n\t * to the full width before being painted, so the highlight reaches the right\n\t * edge instead of stopping at the text. `selectedText` still styles the\n\t * content, so the band is added to that marker rather than replacing it.\n\t * Themes that leave this undefined keep the arrow-and-accent row as-is.\n\t */\n\tselectedRow?: (text: string) => string;\n}\n\nexport interface SelectListTruncatePrimaryContext {\n\ttext: string;\n\tmaxWidth: number;\n\tcolumnWidth: number;\n\titem: SelectItem;\n\tisSelected: boolean;\n}\n\nexport interface SelectListLayoutOptions {\n\tminPrimaryColumnWidth?: number;\n\tmaxPrimaryColumnWidth?: number;\n\ttruncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;\n}\n\nexport class SelectList implements Component {\n\tprivate items: SelectItem[] = [];\n\tprivate filteredItems: SelectItem[] = [];\n\tprivate selectedIndex: number = 0;\n\tprivate maxVisible: number = 5;\n\tprivate theme: SelectListTheme;\n\tprivate layout: SelectListLayoutOptions;\n\n\tpublic onSelect?: (item: SelectItem) => void;\n\tpublic onCancel?: () => void;\n\tpublic onSelectionChange?: (item: SelectItem) => void;\n\n\tconstructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme, layout: SelectListLayoutOptions = {}) {\n\t\tthis.items = items;\n\t\tthis.filteredItems = items;\n\t\tthis.maxVisible = maxVisible;\n\t\tthis.theme = theme;\n\t\tthis.layout = layout;\n\t}\n\n\tsetFilter(filter: string): void {\n\t\tthis.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase()));\n\t\t// Reset selection when filter changes\n\t\tthis.selectedIndex = 0;\n\t}\n\n\tsetSelectedIndex(index: number): void {\n\t\tthis.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));\n\t}\n\n\tinvalidate(): void {\n\t\t// No cached state to invalidate currently\n\t}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\t// If no items match filter, show message\n\t\tif (this.filteredItems.length === 0) {\n\t\t\tlines.push(this.theme.noMatch(\" No matching commands\"));\n\t\t\treturn lines;\n\t\t}\n\n\t\tconst primaryColumnWidth = this.getPrimaryColumnWidth();\n\n\t\t// Calculate visible range with scrolling\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);\n\n\t\t// Render visible items\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst item = this.filteredItems[i];\n\t\t\tif (!item) continue;\n\n\t\t\tconst isSelected = i === this.selectedIndex;\n\t\t\tconst descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined;\n\t\t\tlines.push(this.renderItem(item, isSelected, width, descriptionSingleLine, primaryColumnWidth));\n\t\t}\n\n\t\t// Add scroll indicators if needed\n\t\tif (startIndex > 0 || endIndex < this.filteredItems.length) {\n\t\t\tconst scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`;\n\t\t\t// Truncate if too long for terminal\n\t\t\tlines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, \"\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tconst kb = getKeybindings();\n\t\t// Up arrow - wrap to bottom when at top\n\t\tif (kb.matches(keyData, \"tui.select.up\")) {\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;\n\t\t\tthis.notifySelectionChange();\n\t\t}\n\t\t// Down arrow - wrap to top when at bottom\n\t\telse if (kb.matches(keyData, \"tui.select.down\")) {\n\t\t\tthis.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t\tthis.notifySelectionChange();\n\t\t}\n\t\t// Enter\n\t\telse if (kb.matches(keyData, \"tui.select.confirm\")) {\n\t\t\tconst selectedItem = this.filteredItems[this.selectedIndex];\n\t\t\tif (selectedItem && this.onSelect) {\n\t\t\t\tthis.onSelect(selectedItem);\n\t\t\t}\n\t\t}\n\t\t// Escape or Ctrl+C\n\t\telse if (kb.matches(keyData, \"tui.select.cancel\")) {\n\t\t\tif (this.onCancel) {\n\t\t\t\tthis.onCancel();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate renderItem(\n\t\titem: SelectItem,\n\t\tisSelected: boolean,\n\t\twidth: number,\n\t\tdescriptionSingleLine: string | undefined,\n\t\tprimaryColumnWidth: number,\n\t): string {\n\t\tconst cursor = this.theme.cursor ?? DEFAULT_SELECT_CURSOR;\n\t\tconst prefixWidth = visibleWidth(cursor);\n\t\t// Indent unselected rows by the cursor's width rather than a fixed two\n\t\t// columns, so the value column stays put whatever cursor a theme names.\n\t\tconst prefix = isSelected ? cursor : \" \".repeat(prefixWidth);\n\n\t\tif (descriptionSingleLine && width > 40) {\n\t\t\tconst effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));\n\t\t\tconst maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP);\n\t\t\tconst truncatedValue = this.truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth);\n\t\t\tconst truncatedValueWidth = visibleWidth(truncatedValue);\n\t\t\tconst spacing = \" \".repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth));\n\t\t\tconst descriptionStart = prefixWidth + truncatedValueWidth + spacing.length;\n\t\t\t// Everything to the right of the value column, to the last cell. The\n\t\t\t// description is truncated to exactly this and a selected row is\n\t\t\t// padded out to `width`, so there is nothing for a safety margin to\n\t\t\t// save — it only threw two columns of every picker row away.\n\t\t\tconst remainingWidth = width - descriptionStart;\n\n\t\t\tif (remainingWidth > MIN_DESCRIPTION_WIDTH) {\n\t\t\t\tconst truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, \"\");\n\t\t\t\tif (isSelected) {\n\t\t\t\t\treturn this.renderSelected(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`, width);\n\t\t\t\t}\n\n\t\t\t\tconst descText = this.theme.description(spacing + truncatedDesc);\n\t\t\t\treturn prefix + truncatedValue + descText;\n\t\t\t}\n\t\t}\n\n\t\t// Same as above: the value is truncated to what is left after the cursor,\n\t\t// and that reaches the last cell.\n\t\tconst maxWidth = width - prefixWidth;\n\t\tconst truncatedValue = this.truncatePrimary(item, isSelected, maxWidth, maxWidth);\n\t\tif (isSelected) {\n\t\t\treturn this.renderSelected(`${prefix}${truncatedValue}`, width);\n\t\t}\n\n\t\treturn prefix + truncatedValue;\n\t}\n\n\t/** Style the selected row, and fill it to the edge when the theme asks for a band. */\n\tprivate renderSelected(row: string, width: number): string {\n\t\tconst styled = this.theme.selectedText(row);\n\t\tif (!this.theme.selectedRow) {\n\t\t\treturn styled;\n\t\t}\n\t\treturn applyBackgroundToLine(styled, width, this.theme.selectedRow);\n\t}\n\n\tprivate getPrimaryColumnWidth(): number {\n\t\tconst { min, max } = this.getPrimaryColumnBounds();\n\t\tconst widestPrimary = this.filteredItems.reduce((widest, item) => {\n\t\t\treturn Math.max(widest, visibleWidth(this.getDisplayValue(item)) + PRIMARY_COLUMN_GAP);\n\t\t}, 0);\n\n\t\treturn clamp(widestPrimary, min, max);\n\t}\n\n\tprivate getPrimaryColumnBounds(): { min: number; max: number } {\n\t\tconst rawMin =\n\t\t\tthis.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;\n\t\tconst rawMax =\n\t\t\tthis.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;\n\n\t\treturn {\n\t\t\tmin: Math.max(1, Math.min(rawMin, rawMax)),\n\t\t\tmax: Math.max(1, Math.max(rawMin, rawMax)),\n\t\t};\n\t}\n\n\tprivate truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string {\n\t\tconst displayValue = this.getDisplayValue(item);\n\t\tconst truncatedValue = this.layout.truncatePrimary\n\t\t\t? this.layout.truncatePrimary({\n\t\t\t\t\ttext: displayValue,\n\t\t\t\t\tmaxWidth,\n\t\t\t\t\tcolumnWidth,\n\t\t\t\t\titem,\n\t\t\t\t\tisSelected,\n\t\t\t\t})\n\t\t\t: truncateToWidth(displayValue, maxWidth, \"\");\n\n\t\treturn truncateToWidth(truncatedValue, maxWidth, \"\");\n\t}\n\n\tprivate getDisplayValue(item: SelectItem): string {\n\t\treturn item.label || item.value;\n\t}\n\n\tprivate notifySelectionChange(): void {\n\t\tconst selectedItem = this.filteredItems[this.selectedIndex];\n\t\tif (selectedItem && this.onSelectionChange) {\n\t\t\tthis.onSelectionChange(selectedItem);\n\t\t}\n\t}\n\n\tgetSelectedItem(): SelectItem | null {\n\t\tconst item = this.filteredItems[this.selectedIndex];\n\t\treturn item || null;\n\t}\n}\n"]}
@@ -102,7 +102,11 @@ export class SelectList {
102
102
  const truncatedValueWidth = visibleWidth(truncatedValue);
103
103
  const spacing = " ".repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth));
104
104
  const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length;
105
- const remainingWidth = width - descriptionStart - 2; // -2 for safety
105
+ // Everything to the right of the value column, to the last cell. The
106
+ // description is truncated to exactly this and a selected row is
107
+ // padded out to `width`, so there is nothing for a safety margin to
108
+ // save — it only threw two columns of every picker row away.
109
+ const remainingWidth = width - descriptionStart;
106
110
  if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
107
111
  const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, "");
108
112
  if (isSelected) {
@@ -112,7 +116,9 @@ export class SelectList {
112
116
  return prefix + truncatedValue + descText;
113
117
  }
114
118
  }
115
- const maxWidth = width - prefixWidth - 2;
119
+ // Same as above: the value is truncated to what is left after the cursor,
120
+ // and that reaches the last cell.
121
+ const maxWidth = width - prefixWidth;
116
122
  const truncatedValue = this.truncatePrimary(item, isSelected, maxWidth, maxWidth);
117
123
  if (isSelected) {
118
124
  return this.renderSelected(`${prefix}${truncatedValue}`, width);
@@ -1 +1 @@
1
- {"version":3,"file":"select-list.js","sourceRoot":"","sources":["../../src/components/select-list.ts"],"names":[],"mappings":"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","sourcesContent":["import { getKeybindings } from \"../keybindings.js\";\nimport type { Component } from \"../tui.js\";\nimport { applyBackgroundToLine, truncateToWidth, visibleWidth } from \"../utils.js\";\n\n/** Used when a theme does not name its own cursor. */\nexport const DEFAULT_SELECT_CURSOR = \"→ \";\n\nconst DEFAULT_PRIMARY_COLUMN_WIDTH = 32;\nconst PRIMARY_COLUMN_GAP = 2;\nconst MIN_DESCRIPTION_WIDTH = 10;\n\nconst normalizeToSingleLine = (text: string): string => text.replace(/[\\r\\n]+/g, \" \").trim();\nconst clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max));\n\nexport interface SelectItem {\n\tvalue: string;\n\tlabel: string;\n\tdescription?: string;\n}\n\nexport interface SelectListTheme {\n\tselectedPrefix: (text: string) => string;\n\tselectedText: (text: string) => string;\n\tdescription: (text: string) => string;\n\tscrollInfo: (text: string) => string;\n\tnoMatch: (text: string) => string;\n\t/**\n\t * Optional marker for the selected row, including any trailing space.\n\t * Defaults to `DEFAULT_SELECT_CURSOR`. Unselected rows are indented by the\n\t * cursor's visible width, so the column stays aligned whatever it is set to.\n\t */\n\tcursor?: string;\n\t/**\n\t * Optional background for the selected row. When set, the row is padded out\n\t * to the full width before being painted, so the highlight reaches the right\n\t * edge instead of stopping at the text. `selectedText` still styles the\n\t * content, so the band is added to that marker rather than replacing it.\n\t * Themes that leave this undefined keep the arrow-and-accent row as-is.\n\t */\n\tselectedRow?: (text: string) => string;\n}\n\nexport interface SelectListTruncatePrimaryContext {\n\ttext: string;\n\tmaxWidth: number;\n\tcolumnWidth: number;\n\titem: SelectItem;\n\tisSelected: boolean;\n}\n\nexport interface SelectListLayoutOptions {\n\tminPrimaryColumnWidth?: number;\n\tmaxPrimaryColumnWidth?: number;\n\ttruncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;\n}\n\nexport class SelectList implements Component {\n\tprivate items: SelectItem[] = [];\n\tprivate filteredItems: SelectItem[] = [];\n\tprivate selectedIndex: number = 0;\n\tprivate maxVisible: number = 5;\n\tprivate theme: SelectListTheme;\n\tprivate layout: SelectListLayoutOptions;\n\n\tpublic onSelect?: (item: SelectItem) => void;\n\tpublic onCancel?: () => void;\n\tpublic onSelectionChange?: (item: SelectItem) => void;\n\n\tconstructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme, layout: SelectListLayoutOptions = {}) {\n\t\tthis.items = items;\n\t\tthis.filteredItems = items;\n\t\tthis.maxVisible = maxVisible;\n\t\tthis.theme = theme;\n\t\tthis.layout = layout;\n\t}\n\n\tsetFilter(filter: string): void {\n\t\tthis.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase()));\n\t\t// Reset selection when filter changes\n\t\tthis.selectedIndex = 0;\n\t}\n\n\tsetSelectedIndex(index: number): void {\n\t\tthis.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));\n\t}\n\n\tinvalidate(): void {\n\t\t// No cached state to invalidate currently\n\t}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\t// If no items match filter, show message\n\t\tif (this.filteredItems.length === 0) {\n\t\t\tlines.push(this.theme.noMatch(\" No matching commands\"));\n\t\t\treturn lines;\n\t\t}\n\n\t\tconst primaryColumnWidth = this.getPrimaryColumnWidth();\n\n\t\t// Calculate visible range with scrolling\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);\n\n\t\t// Render visible items\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst item = this.filteredItems[i];\n\t\t\tif (!item) continue;\n\n\t\t\tconst isSelected = i === this.selectedIndex;\n\t\t\tconst descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined;\n\t\t\tlines.push(this.renderItem(item, isSelected, width, descriptionSingleLine, primaryColumnWidth));\n\t\t}\n\n\t\t// Add scroll indicators if needed\n\t\tif (startIndex > 0 || endIndex < this.filteredItems.length) {\n\t\t\tconst scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`;\n\t\t\t// Truncate if too long for terminal\n\t\t\tlines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, \"\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tconst kb = getKeybindings();\n\t\t// Up arrow - wrap to bottom when at top\n\t\tif (kb.matches(keyData, \"tui.select.up\")) {\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;\n\t\t\tthis.notifySelectionChange();\n\t\t}\n\t\t// Down arrow - wrap to top when at bottom\n\t\telse if (kb.matches(keyData, \"tui.select.down\")) {\n\t\t\tthis.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t\tthis.notifySelectionChange();\n\t\t}\n\t\t// Enter\n\t\telse if (kb.matches(keyData, \"tui.select.confirm\")) {\n\t\t\tconst selectedItem = this.filteredItems[this.selectedIndex];\n\t\t\tif (selectedItem && this.onSelect) {\n\t\t\t\tthis.onSelect(selectedItem);\n\t\t\t}\n\t\t}\n\t\t// Escape or Ctrl+C\n\t\telse if (kb.matches(keyData, \"tui.select.cancel\")) {\n\t\t\tif (this.onCancel) {\n\t\t\t\tthis.onCancel();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate renderItem(\n\t\titem: SelectItem,\n\t\tisSelected: boolean,\n\t\twidth: number,\n\t\tdescriptionSingleLine: string | undefined,\n\t\tprimaryColumnWidth: number,\n\t): string {\n\t\tconst cursor = this.theme.cursor ?? DEFAULT_SELECT_CURSOR;\n\t\tconst prefixWidth = visibleWidth(cursor);\n\t\t// Indent unselected rows by the cursor's width rather than a fixed two\n\t\t// columns, so the value column stays put whatever cursor a theme names.\n\t\tconst prefix = isSelected ? cursor : \" \".repeat(prefixWidth);\n\n\t\tif (descriptionSingleLine && width > 40) {\n\t\t\tconst effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));\n\t\t\tconst maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP);\n\t\t\tconst truncatedValue = this.truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth);\n\t\t\tconst truncatedValueWidth = visibleWidth(truncatedValue);\n\t\t\tconst spacing = \" \".repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth));\n\t\t\tconst descriptionStart = prefixWidth + truncatedValueWidth + spacing.length;\n\t\t\tconst remainingWidth = width - descriptionStart - 2; // -2 for safety\n\n\t\t\tif (remainingWidth > MIN_DESCRIPTION_WIDTH) {\n\t\t\t\tconst truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, \"\");\n\t\t\t\tif (isSelected) {\n\t\t\t\t\treturn this.renderSelected(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`, width);\n\t\t\t\t}\n\n\t\t\t\tconst descText = this.theme.description(spacing + truncatedDesc);\n\t\t\t\treturn prefix + truncatedValue + descText;\n\t\t\t}\n\t\t}\n\n\t\tconst maxWidth = width - prefixWidth - 2;\n\t\tconst truncatedValue = this.truncatePrimary(item, isSelected, maxWidth, maxWidth);\n\t\tif (isSelected) {\n\t\t\treturn this.renderSelected(`${prefix}${truncatedValue}`, width);\n\t\t}\n\n\t\treturn prefix + truncatedValue;\n\t}\n\n\t/** Style the selected row, and fill it to the edge when the theme asks for a band. */\n\tprivate renderSelected(row: string, width: number): string {\n\t\tconst styled = this.theme.selectedText(row);\n\t\tif (!this.theme.selectedRow) {\n\t\t\treturn styled;\n\t\t}\n\t\treturn applyBackgroundToLine(styled, width, this.theme.selectedRow);\n\t}\n\n\tprivate getPrimaryColumnWidth(): number {\n\t\tconst { min, max } = this.getPrimaryColumnBounds();\n\t\tconst widestPrimary = this.filteredItems.reduce((widest, item) => {\n\t\t\treturn Math.max(widest, visibleWidth(this.getDisplayValue(item)) + PRIMARY_COLUMN_GAP);\n\t\t}, 0);\n\n\t\treturn clamp(widestPrimary, min, max);\n\t}\n\n\tprivate getPrimaryColumnBounds(): { min: number; max: number } {\n\t\tconst rawMin =\n\t\t\tthis.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;\n\t\tconst rawMax =\n\t\t\tthis.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;\n\n\t\treturn {\n\t\t\tmin: Math.max(1, Math.min(rawMin, rawMax)),\n\t\t\tmax: Math.max(1, Math.max(rawMin, rawMax)),\n\t\t};\n\t}\n\n\tprivate truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string {\n\t\tconst displayValue = this.getDisplayValue(item);\n\t\tconst truncatedValue = this.layout.truncatePrimary\n\t\t\t? this.layout.truncatePrimary({\n\t\t\t\t\ttext: displayValue,\n\t\t\t\t\tmaxWidth,\n\t\t\t\t\tcolumnWidth,\n\t\t\t\t\titem,\n\t\t\t\t\tisSelected,\n\t\t\t\t})\n\t\t\t: truncateToWidth(displayValue, maxWidth, \"\");\n\n\t\treturn truncateToWidth(truncatedValue, maxWidth, \"\");\n\t}\n\n\tprivate getDisplayValue(item: SelectItem): string {\n\t\treturn item.label || item.value;\n\t}\n\n\tprivate notifySelectionChange(): void {\n\t\tconst selectedItem = this.filteredItems[this.selectedIndex];\n\t\tif (selectedItem && this.onSelectionChange) {\n\t\t\tthis.onSelectionChange(selectedItem);\n\t\t}\n\t}\n\n\tgetSelectedItem(): SelectItem | null {\n\t\tconst item = this.filteredItems[this.selectedIndex];\n\t\treturn item || null;\n\t}\n}\n"]}
1
+ {"version":3,"file":"select-list.js","sourceRoot":"","sources":["../../src/components/select-list.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEnD,OAAO,EAAE,qBAAqB,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEnF,sDAAsD;AACtD,MAAM,CAAC,MAAM,qBAAqB,GAAG,MAAI,CAAC;AAE1C,MAAM,4BAA4B,GAAG,EAAE,CAAC;AACxC,MAAM,kBAAkB,GAAG,CAAC,CAAC;AAC7B,MAAM,qBAAqB,GAAG,EAAE,CAAC;AAEjC,MAAM,qBAAqB,GAAG,CAAC,IAAY,EAAU,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;AAC7F,MAAM,KAAK,GAAG,CAAC,KAAa,EAAE,GAAW,EAAE,GAAW,EAAU,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC;AA4CvG,MAAM,OAAO,UAAU;IACd,KAAK,GAAiB,EAAE,CAAC;IACzB,aAAa,GAAiB,EAAE,CAAC;IACjC,aAAa,GAAW,CAAC,CAAC;IAC1B,UAAU,GAAW,CAAC,CAAC;IACvB,KAAK,CAAkB;IACvB,MAAM,CAA0B;IAEjC,QAAQ,CAA8B;IACtC,QAAQ,CAAc;IACtB,iBAAiB,CAA8B;IAEtD,YAAY,KAAmB,EAAE,UAAkB,EAAE,KAAsB,EAAE,MAAM,GAA4B,EAAE,EAAE;QAClH,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,aAAa,GAAG,KAAK,CAAC;QAC3B,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IAAA,CACrB;IAED,SAAS,CAAC,MAAc,EAAQ;QAC/B,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC,UAAU,CAAC,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;QAC5G,sCAAsC;QACtC,IAAI,CAAC,aAAa,GAAG,CAAC,CAAC;IAAA,CACvB;IAED,gBAAgB,CAAC,KAAa,EAAQ;QACrC,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,aAAa,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC;IAAA,CACjF;IAED,UAAU,GAAS;QAClB,0CAA0C;IADvB,CAEnB;IAED,MAAM,CAAC,KAAa,EAAY;QAC/B,MAAM,KAAK,GAAa,EAAE,CAAC;QAE3B,yCAAyC;QACzC,IAAI,IAAI,CAAC,aAAa,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACrC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,wBAAwB,CAAC,CAAC,CAAC;YACzD,OAAO,KAAK,CAAC;QACd,CAAC;QAED,MAAM,kBAAkB,GAAG,IAAI,CAAC,qBAAqB,EAAE,CAAC;QAExD,yCAAyC;QACzC,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,CAC1B,CAAC,EACD,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,aAAa,CAAC,MAAM,GAAG,IAAI,CAAC,UAAU,CAAC,CAC3G,CAAC;QACF,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;QAEnF,uBAAuB;QACvB,KAAK,IAAI,CAAC,GAAG,UAAU,EAAE,CAAC,GAAG,QAAQ,EAAE,CAAC,EAAE,EAAE,CAAC;YAC5C,MAAM,IAAI,GAAG,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC;YACnC,IAAI,CAAC,IAAI;gBAAE,SAAS;YAEpB,MAAM,UAAU,GAAG,CAAC,KAAK,IAAI,CAAC,aAAa,CAAC;YAC5C,MAAM,qBAAqB,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,qBAAqB,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;YACrG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,qBAAqB,EAAE,kBAAkB,CAAC,CAAC,CAAC;QACjG,CAAC;QAED,kCAAkC;QAClC,IAAI,UAAU,GAAG,CAAC,IAAI,QAAQ,GAAG,IAAI,CAAC,aAAa,CAAC,MAAM,EAAE,CAAC;YAC5D,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,aAAa,GAAG,CAAC,IAAI,IAAI,CAAC,aAAa,CAAC,MAAM,GAAG,CAAC;YAChF,oCAAoC;YACpC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,eAAe,CAAC,UAAU,EAAE,KAAK,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;QAC/E,CAAC;QAED,OAAO,KAAK,CAAC;IAAA,CACb;IAED,WAAW,CAAC,OAAe,EAAQ;QAClC,MAAM,EAAE,GAAG,cAAc,EAAE,CAAC;QAC5B,wCAAwC;QACxC,IAAI,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,eAAe,CAAC,EAAE,CAAC;YAC1C,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,aAAa,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,aAAa,GAAG,CAAC,CAAC;YACvG,IAAI,CAAC,qBAAqB,EAAE,CAAC;QAC9B,CAAC;QACD,0CAA0C;aACrC,IAAI,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,iBAAiB,CAAC,EAAE,CAAC;YACjD,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,aAAa,KAAK,IAAI,CAAC,aAAa,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,aAAa,GAAG,CAAC,CAAC;YACvG,IAAI,CAAC,qBAAqB,EAAE,CAAC;QAC9B,CAAC;QACD,QAAQ;aACH,IAAI,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,oBAAoB,CAAC,EAAE,CAAC;YACpD,MAAM,YAAY,GAAG,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;YAC5D,IAAI,YAAY,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACnC,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;YAC7B,CAAC;QACF,CAAC;QACD,mBAAmB;aACd,IAAI,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,mBAAmB,CAAC,EAAE,CAAC;YACnD,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACnB,IAAI,CAAC,QAAQ,EAAE,CAAC;YACjB,CAAC;QACF,CAAC;IAAA,CACD;IAEO,UAAU,CACjB,IAAgB,EAChB,UAAmB,EACnB,KAAa,EACb,qBAAyC,EACzC,kBAA0B,EACjB;QACT,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,qBAAqB,CAAC;QAC1D,MAAM,WAAW,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;QACzC,uEAAuE;QACvE,wEAAwE;QACxE,MAAM,MAAM,GAAG,UAAU,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;QAE7D,IAAI,qBAAqB,IAAI,KAAK,GAAG,EAAE,EAAE,CAAC;YACzC,MAAM,2BAA2B,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,kBAAkB,EAAE,KAAK,GAAG,WAAW,GAAG,CAAC,CAAC,CAAC,CAAC;YACvG,MAAM,eAAe,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,2BAA2B,GAAG,kBAAkB,CAAC,CAAC;YACtF,MAAM,cAAc,GAAG,IAAI,CAAC,eAAe,CAAC,IAAI,EAAE,UAAU,EAAE,eAAe,EAAE,2BAA2B,CAAC,CAAC;YAC5G,MAAM,mBAAmB,GAAG,YAAY,CAAC,cAAc,CAAC,CAAC;YACzD,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,2BAA2B,GAAG,mBAAmB,CAAC,CAAC,CAAC;YAC3F,MAAM,gBAAgB,GAAG,WAAW,GAAG,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC;YAC5E,qEAAqE;YACrE,iEAAiE;YACjE,oEAAoE;YACpE,+DAA6D;YAC7D,MAAM,cAAc,GAAG,KAAK,GAAG,gBAAgB,CAAC;YAEhD,IAAI,cAAc,GAAG,qBAAqB,EAAE,CAAC;gBAC5C,MAAM,aAAa,GAAG,eAAe,CAAC,qBAAqB,EAAE,cAAc,EAAE,EAAE,CAAC,CAAC;gBACjF,IAAI,UAAU,EAAE,CAAC;oBAChB,OAAO,IAAI,CAAC,cAAc,CAAC,GAAG,MAAM,GAAG,cAAc,GAAG,OAAO,GAAG,aAAa,EAAE,EAAE,KAAK,CAAC,CAAC;gBAC3F,CAAC;gBAED,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,OAAO,GAAG,aAAa,CAAC,CAAC;gBACjE,OAAO,MAAM,GAAG,cAAc,GAAG,QAAQ,CAAC;YAC3C,CAAC;QACF,CAAC;QAED,0EAA0E;QAC1E,kCAAkC;QAClC,MAAM,QAAQ,GAAG,KAAK,GAAG,WAAW,CAAC;QACrC,MAAM,cAAc,GAAG,IAAI,CAAC,eAAe,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC;QAClF,IAAI,UAAU,EAAE,CAAC;YAChB,OAAO,IAAI,CAAC,cAAc,CAAC,GAAG,MAAM,GAAG,cAAc,EAAE,EAAE,KAAK,CAAC,CAAC;QACjE,CAAC;QAED,OAAO,MAAM,GAAG,cAAc,CAAC;IAAA,CAC/B;IAED,sFAAsF;IAC9E,cAAc,CAAC,GAAW,EAAE,KAAa,EAAU;QAC1D,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC;QAC5C,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;YAC7B,OAAO,MAAM,CAAC;QACf,CAAC;QACD,OAAO,qBAAqB,CAAC,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;IAAA,CACpE;IAEO,qBAAqB,GAAW;QACvC,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,sBAAsB,EAAE,CAAC;QACnD,MAAM,aAAa,GAAG,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC;YACjE,OAAO,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC,GAAG,kBAAkB,CAAC,CAAC;QAAA,CACvF,EAAE,CAAC,CAAC,CAAC;QAEN,OAAO,KAAK,CAAC,aAAa,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC;IAAA,CACtC;IAEO,sBAAsB,GAAiC;QAC9D,MAAM,MAAM,GACX,IAAI,CAAC,MAAM,CAAC,qBAAqB,IAAI,IAAI,CAAC,MAAM,CAAC,qBAAqB,IAAI,4BAA4B,CAAC;QACxG,MAAM,MAAM,GACX,IAAI,CAAC,MAAM,CAAC,qBAAqB,IAAI,IAAI,CAAC,MAAM,CAAC,qBAAqB,IAAI,4BAA4B,CAAC;QAExG,OAAO;YACN,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;YAC1C,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;SAC1C,CAAC;IAAA,CACF;IAEO,eAAe,CAAC,IAAgB,EAAE,UAAmB,EAAE,QAAgB,EAAE,WAAmB,EAAU;QAC7G,MAAM,YAAY,GAAG,IAAI,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;QAChD,MAAM,cAAc,GAAG,IAAI,CAAC,MAAM,CAAC,eAAe;YACjD,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,eAAe,CAAC;gBAC5B,IAAI,EAAE,YAAY;gBAClB,QAAQ;gBACR,WAAW;gBACX,IAAI;gBACJ,UAAU;aACV,CAAC;YACH,CAAC,CAAC,eAAe,CAAC,YAAY,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC;QAE/C,OAAO,eAAe,CAAC,cAAc,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC;IAAA,CACrD;IAEO,eAAe,CAAC,IAAgB,EAAU;QACjD,OAAO,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC;IAAA,CAChC;IAEO,qBAAqB,GAAS;QACrC,MAAM,YAAY,GAAG,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;QAC5D,IAAI,YAAY,IAAI,IAAI,CAAC,iBAAiB,EAAE,CAAC;YAC5C,IAAI,CAAC,iBAAiB,CAAC,YAAY,CAAC,CAAC;QACtC,CAAC;IAAA,CACD;IAED,eAAe,GAAsB;QACpC,MAAM,IAAI,GAAG,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;QACpD,OAAO,IAAI,IAAI,IAAI,CAAC;IAAA,CACpB;CACD","sourcesContent":["import { getKeybindings } from \"../keybindings.js\";\nimport type { Component } from \"../tui.js\";\nimport { applyBackgroundToLine, truncateToWidth, visibleWidth } from \"../utils.js\";\n\n/** Used when a theme does not name its own cursor. */\nexport const DEFAULT_SELECT_CURSOR = \"→ \";\n\nconst DEFAULT_PRIMARY_COLUMN_WIDTH = 32;\nconst PRIMARY_COLUMN_GAP = 2;\nconst MIN_DESCRIPTION_WIDTH = 10;\n\nconst normalizeToSingleLine = (text: string): string => text.replace(/[\\r\\n]+/g, \" \").trim();\nconst clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max));\n\nexport interface SelectItem {\n\tvalue: string;\n\tlabel: string;\n\tdescription?: string;\n}\n\nexport interface SelectListTheme {\n\tselectedPrefix: (text: string) => string;\n\tselectedText: (text: string) => string;\n\tdescription: (text: string) => string;\n\tscrollInfo: (text: string) => string;\n\tnoMatch: (text: string) => string;\n\t/**\n\t * Optional marker for the selected row, including any trailing space.\n\t * Defaults to `DEFAULT_SELECT_CURSOR`. Unselected rows are indented by the\n\t * cursor's visible width, so the column stays aligned whatever it is set to.\n\t */\n\tcursor?: string;\n\t/**\n\t * Optional background for the selected row. When set, the row is padded out\n\t * to the full width before being painted, so the highlight reaches the right\n\t * edge instead of stopping at the text. `selectedText` still styles the\n\t * content, so the band is added to that marker rather than replacing it.\n\t * Themes that leave this undefined keep the arrow-and-accent row as-is.\n\t */\n\tselectedRow?: (text: string) => string;\n}\n\nexport interface SelectListTruncatePrimaryContext {\n\ttext: string;\n\tmaxWidth: number;\n\tcolumnWidth: number;\n\titem: SelectItem;\n\tisSelected: boolean;\n}\n\nexport interface SelectListLayoutOptions {\n\tminPrimaryColumnWidth?: number;\n\tmaxPrimaryColumnWidth?: number;\n\ttruncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;\n}\n\nexport class SelectList implements Component {\n\tprivate items: SelectItem[] = [];\n\tprivate filteredItems: SelectItem[] = [];\n\tprivate selectedIndex: number = 0;\n\tprivate maxVisible: number = 5;\n\tprivate theme: SelectListTheme;\n\tprivate layout: SelectListLayoutOptions;\n\n\tpublic onSelect?: (item: SelectItem) => void;\n\tpublic onCancel?: () => void;\n\tpublic onSelectionChange?: (item: SelectItem) => void;\n\n\tconstructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme, layout: SelectListLayoutOptions = {}) {\n\t\tthis.items = items;\n\t\tthis.filteredItems = items;\n\t\tthis.maxVisible = maxVisible;\n\t\tthis.theme = theme;\n\t\tthis.layout = layout;\n\t}\n\n\tsetFilter(filter: string): void {\n\t\tthis.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase()));\n\t\t// Reset selection when filter changes\n\t\tthis.selectedIndex = 0;\n\t}\n\n\tsetSelectedIndex(index: number): void {\n\t\tthis.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));\n\t}\n\n\tinvalidate(): void {\n\t\t// No cached state to invalidate currently\n\t}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\t// If no items match filter, show message\n\t\tif (this.filteredItems.length === 0) {\n\t\t\tlines.push(this.theme.noMatch(\" No matching commands\"));\n\t\t\treturn lines;\n\t\t}\n\n\t\tconst primaryColumnWidth = this.getPrimaryColumnWidth();\n\n\t\t// Calculate visible range with scrolling\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);\n\n\t\t// Render visible items\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst item = this.filteredItems[i];\n\t\t\tif (!item) continue;\n\n\t\t\tconst isSelected = i === this.selectedIndex;\n\t\t\tconst descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined;\n\t\t\tlines.push(this.renderItem(item, isSelected, width, descriptionSingleLine, primaryColumnWidth));\n\t\t}\n\n\t\t// Add scroll indicators if needed\n\t\tif (startIndex > 0 || endIndex < this.filteredItems.length) {\n\t\t\tconst scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`;\n\t\t\t// Truncate if too long for terminal\n\t\t\tlines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, \"\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\thandleInput(keyData: string): void {\n\t\tconst kb = getKeybindings();\n\t\t// Up arrow - wrap to bottom when at top\n\t\tif (kb.matches(keyData, \"tui.select.up\")) {\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;\n\t\t\tthis.notifySelectionChange();\n\t\t}\n\t\t// Down arrow - wrap to top when at bottom\n\t\telse if (kb.matches(keyData, \"tui.select.down\")) {\n\t\t\tthis.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t\tthis.notifySelectionChange();\n\t\t}\n\t\t// Enter\n\t\telse if (kb.matches(keyData, \"tui.select.confirm\")) {\n\t\t\tconst selectedItem = this.filteredItems[this.selectedIndex];\n\t\t\tif (selectedItem && this.onSelect) {\n\t\t\t\tthis.onSelect(selectedItem);\n\t\t\t}\n\t\t}\n\t\t// Escape or Ctrl+C\n\t\telse if (kb.matches(keyData, \"tui.select.cancel\")) {\n\t\t\tif (this.onCancel) {\n\t\t\t\tthis.onCancel();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate renderItem(\n\t\titem: SelectItem,\n\t\tisSelected: boolean,\n\t\twidth: number,\n\t\tdescriptionSingleLine: string | undefined,\n\t\tprimaryColumnWidth: number,\n\t): string {\n\t\tconst cursor = this.theme.cursor ?? DEFAULT_SELECT_CURSOR;\n\t\tconst prefixWidth = visibleWidth(cursor);\n\t\t// Indent unselected rows by the cursor's width rather than a fixed two\n\t\t// columns, so the value column stays put whatever cursor a theme names.\n\t\tconst prefix = isSelected ? cursor : \" \".repeat(prefixWidth);\n\n\t\tif (descriptionSingleLine && width > 40) {\n\t\t\tconst effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));\n\t\t\tconst maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP);\n\t\t\tconst truncatedValue = this.truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth);\n\t\t\tconst truncatedValueWidth = visibleWidth(truncatedValue);\n\t\t\tconst spacing = \" \".repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth));\n\t\t\tconst descriptionStart = prefixWidth + truncatedValueWidth + spacing.length;\n\t\t\t// Everything to the right of the value column, to the last cell. The\n\t\t\t// description is truncated to exactly this and a selected row is\n\t\t\t// padded out to `width`, so there is nothing for a safety margin to\n\t\t\t// save — it only threw two columns of every picker row away.\n\t\t\tconst remainingWidth = width - descriptionStart;\n\n\t\t\tif (remainingWidth > MIN_DESCRIPTION_WIDTH) {\n\t\t\t\tconst truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, \"\");\n\t\t\t\tif (isSelected) {\n\t\t\t\t\treturn this.renderSelected(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`, width);\n\t\t\t\t}\n\n\t\t\t\tconst descText = this.theme.description(spacing + truncatedDesc);\n\t\t\t\treturn prefix + truncatedValue + descText;\n\t\t\t}\n\t\t}\n\n\t\t// Same as above: the value is truncated to what is left after the cursor,\n\t\t// and that reaches the last cell.\n\t\tconst maxWidth = width - prefixWidth;\n\t\tconst truncatedValue = this.truncatePrimary(item, isSelected, maxWidth, maxWidth);\n\t\tif (isSelected) {\n\t\t\treturn this.renderSelected(`${prefix}${truncatedValue}`, width);\n\t\t}\n\n\t\treturn prefix + truncatedValue;\n\t}\n\n\t/** Style the selected row, and fill it to the edge when the theme asks for a band. */\n\tprivate renderSelected(row: string, width: number): string {\n\t\tconst styled = this.theme.selectedText(row);\n\t\tif (!this.theme.selectedRow) {\n\t\t\treturn styled;\n\t\t}\n\t\treturn applyBackgroundToLine(styled, width, this.theme.selectedRow);\n\t}\n\n\tprivate getPrimaryColumnWidth(): number {\n\t\tconst { min, max } = this.getPrimaryColumnBounds();\n\t\tconst widestPrimary = this.filteredItems.reduce((widest, item) => {\n\t\t\treturn Math.max(widest, visibleWidth(this.getDisplayValue(item)) + PRIMARY_COLUMN_GAP);\n\t\t}, 0);\n\n\t\treturn clamp(widestPrimary, min, max);\n\t}\n\n\tprivate getPrimaryColumnBounds(): { min: number; max: number } {\n\t\tconst rawMin =\n\t\t\tthis.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;\n\t\tconst rawMax =\n\t\t\tthis.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;\n\n\t\treturn {\n\t\t\tmin: Math.max(1, Math.min(rawMin, rawMax)),\n\t\t\tmax: Math.max(1, Math.max(rawMin, rawMax)),\n\t\t};\n\t}\n\n\tprivate truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string {\n\t\tconst displayValue = this.getDisplayValue(item);\n\t\tconst truncatedValue = this.layout.truncatePrimary\n\t\t\t? this.layout.truncatePrimary({\n\t\t\t\t\ttext: displayValue,\n\t\t\t\t\tmaxWidth,\n\t\t\t\t\tcolumnWidth,\n\t\t\t\t\titem,\n\t\t\t\t\tisSelected,\n\t\t\t\t})\n\t\t\t: truncateToWidth(displayValue, maxWidth, \"\");\n\n\t\treturn truncateToWidth(truncatedValue, maxWidth, \"\");\n\t}\n\n\tprivate getDisplayValue(item: SelectItem): string {\n\t\treturn item.label || item.value;\n\t}\n\n\tprivate notifySelectionChange(): void {\n\t\tconst selectedItem = this.filteredItems[this.selectedIndex];\n\t\tif (selectedItem && this.onSelectionChange) {\n\t\t\tthis.onSelectionChange(selectedItem);\n\t\t}\n\t}\n\n\tgetSelectedItem(): SelectItem | null {\n\t\tconst item = this.filteredItems[this.selectedIndex];\n\t\treturn item || null;\n\t}\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"settings-list.d.ts","sourceRoot":"","sources":["../../src/components/settings-list.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAI3C,MAAM,WAAW,WAAW;IAC3B,yCAAyC;IACzC,EAAE,EAAE,MAAM,CAAC;IACX,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4CAA4C;IAC5C,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,uFAAuF;IACvF,OAAO,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,aAAa,CAAC,EAAE,MAAM,KAAK,IAAI,KAAK,SAAS,CAAC;CACtF;AAED,MAAM,WAAW,iBAAiB;IACjC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,KAAK,MAAM,CAAC;IACnD,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,KAAK,MAAM,CAAC;IACnD,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CACvC;AAED,MAAM,WAAW,mBAAmB;IACnC,YAAY,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,qBAAa,YAAa,YAAW,SAAS;IAC7C,OAAO,CAAC,KAAK,CAAgB;IAC7B,OAAO,CAAC,aAAa,CAAgB;IACrC,OAAO,CAAC,KAAK,CAAoB;IACjC,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,QAAQ,CAAyC;IACzD,OAAO,CAAC,QAAQ,CAAa;IAC7B,OAAO,CAAC,WAAW,CAAC,CAAQ;IAC5B,OAAO,CAAC,aAAa,CAAU;IAG/B,OAAO,CAAC,gBAAgB,CAA0B;IAClD,OAAO,CAAC,gBAAgB,CAAuB;IAE/C,YACC,KAAK,EAAE,WAAW,EAAE,EACpB,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,iBAAiB,EACxB,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EAChD,QAAQ,EAAE,MAAM,IAAI,EACpB,OAAO,GAAE,mBAAwB,EAYjC;IAED,oCAAoC;IACpC,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAK9C;IAED,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAO9B;IAED,OAAO,CAAC,cAAc;IA8FtB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CA6B9B;IAED,OAAO,CAAC,YAAY;IAwBpB,OAAO,CAAC,YAAY;IASpB,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,WAAW;CAanB","sourcesContent":["import { fuzzyFilter } from \"../fuzzy.js\";\nimport { getKeybindings } from \"../keybindings.js\";\nimport type { Component } from \"../tui.js\";\nimport { applyBackgroundToLine, truncateToWidth, visibleWidth, wrapTextWithAnsi } from \"../utils.js\";\nimport { Input } from \"./input.js\";\n\nexport interface SettingItem {\n\t/** Unique identifier for this setting */\n\tid: string;\n\t/** Display label (left side) */\n\tlabel: string;\n\t/** Optional description shown when selected */\n\tdescription?: string;\n\t/** Current value to display (right side) */\n\tcurrentValue: string;\n\t/**\n\t * Display-only text rendered after the value, dimmed and never cycled. For\n\t * what a row *costs* rather than what it is set to - a token price, a count -\n\t * which would otherwise have to be folded into `currentValue`, where the next\n\t * keypress would fail to find it among `values` and reset the row.\n\t */\n\tvalueSuffix?: string;\n\t/**\n\t * Extra text the search matches against, never rendered. A row that stands for\n\t * other rows (a category) lists what is inside it here, so searching for a\n\t * setting finds the row that leads to it instead of coming back empty.\n\t */\n\tkeywords?: string;\n\t/** If provided, Enter/Space cycles through these values */\n\tvalues?: string[];\n\t/** If provided, Enter opens this submenu. Receives current value and done callback. */\n\tsubmenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;\n}\n\nexport interface SettingsListTheme {\n\tlabel: (text: string, selected: boolean) => string;\n\tvalue: (text: string, selected: boolean) => string;\n\tdescription: (text: string) => string;\n\tcursor: string;\n\thint: (text: string) => string;\n\t/**\n\t * Optional background for the selected row. When set, the row is padded out\n\t * to the full width before being painted, so the highlight reaches the right\n\t * edge instead of stopping at the value. `label` and `value` still style the\n\t * content, so the band is added to that marker rather than replacing it.\n\t * Themes that leave this undefined keep the cursor-and-accent row as-is.\n\t */\n\tselectedRow?: (text: string) => string;\n}\n\nexport interface SettingsListOptions {\n\tenableSearch?: boolean;\n}\n\nexport class SettingsList implements Component {\n\tprivate items: SettingItem[];\n\tprivate filteredItems: SettingItem[];\n\tprivate theme: SettingsListTheme;\n\tprivate selectedIndex = 0;\n\tprivate maxVisible: number;\n\tprivate onChange: (id: string, newValue: string) => void;\n\tprivate onCancel: () => void;\n\tprivate searchInput?: Input;\n\tprivate searchEnabled: boolean;\n\n\t// Submenu state\n\tprivate submenuComponent: Component | null = null;\n\tprivate submenuItemIndex: number | null = null;\n\n\tconstructor(\n\t\titems: SettingItem[],\n\t\tmaxVisible: number,\n\t\ttheme: SettingsListTheme,\n\t\tonChange: (id: string, newValue: string) => void,\n\t\tonCancel: () => void,\n\t\toptions: SettingsListOptions = {},\n\t) {\n\t\tthis.items = items;\n\t\tthis.filteredItems = items;\n\t\tthis.maxVisible = maxVisible;\n\t\tthis.theme = theme;\n\t\tthis.onChange = onChange;\n\t\tthis.onCancel = onCancel;\n\t\tthis.searchEnabled = options.enableSearch ?? false;\n\t\tif (this.searchEnabled) {\n\t\t\tthis.searchInput = new Input();\n\t\t}\n\t}\n\n\t/** Update an item's currentValue */\n\tupdateValue(id: string, newValue: string): void {\n\t\tconst item = this.items.find((i) => i.id === id);\n\t\tif (item) {\n\t\t\titem.currentValue = newValue;\n\t\t}\n\t}\n\n\tinvalidate(): void {\n\t\tthis.submenuComponent?.invalidate?.();\n\t}\n\n\trender(width: number): string[] {\n\t\t// If submenu is active, render it instead\n\t\tif (this.submenuComponent) {\n\t\t\treturn this.submenuComponent.render(width);\n\t\t}\n\n\t\treturn this.renderMainList(width);\n\t}\n\n\tprivate renderMainList(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\tif (this.searchEnabled && this.searchInput) {\n\t\t\tlines.push(...this.searchInput.render(width));\n\t\t\tlines.push(\"\");\n\t\t}\n\n\t\tif (this.items.length === 0) {\n\t\t\tlines.push(this.theme.hint(\" No settings available\"));\n\t\t\tif (this.searchEnabled) {\n\t\t\t\tthis.addHintLine(lines, width);\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\tconst displayItems = this.searchEnabled ? this.filteredItems : this.items;\n\t\tif (displayItems.length === 0) {\n\t\t\tlines.push(truncateToWidth(this.theme.hint(\" No matching settings\"), width));\n\t\t\tthis.addHintLine(lines, width);\n\t\t\treturn lines;\n\t\t}\n\n\t\t// Calculate visible range with scrolling\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);\n\n\t\t// Calculate max label width for alignment\n\t\tconst maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));\n\n\t\t// Render visible items\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst item = displayItems[i];\n\t\t\tif (!item) continue;\n\n\t\t\tconst isSelected = i === this.selectedIndex;\n\t\t\t// Indent unselected rows by the cursor's width rather than a fixed two\n\t\t\t// columns, so the label column stays put whatever cursor a theme names.\n\t\t\tconst prefixWidth = visibleWidth(this.theme.cursor);\n\t\t\tconst prefix = isSelected ? this.theme.cursor : \" \".repeat(prefixWidth);\n\n\t\t\t// Pad label to align values\n\t\t\tconst labelPadded = item.label + \" \".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));\n\t\t\tconst labelText = this.theme.label(labelPadded, isSelected);\n\n\t\t\t// Calculate space for value\n\t\t\tconst separator = \" \";\n\t\t\tconst usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);\n\t\t\tconst valueMaxWidth = width - usedWidth - 2;\n\n\t\t\tconst valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, \"\"), isSelected);\n\t\t\t// The suffix takes what the value left behind, so a narrow terminal drops\n\t\t\t// the price rather than the setting.\n\t\t\tconst suffixText = item.valueSuffix\n\t\t\t\t? this.theme.hint(\n\t\t\t\t\t\ttruncateToWidth(\n\t\t\t\t\t\t\t` ${item.valueSuffix}`,\n\t\t\t\t\t\t\tMath.max(0, valueMaxWidth - visibleWidth(item.currentValue)),\n\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t: \"\";\n\n\t\t\tconst row = truncateToWidth(prefix + labelText + separator + valueText + suffixText, width);\n\t\t\tlines.push(\n\t\t\t\tisSelected && this.theme.selectedRow ? applyBackgroundToLine(row, width, this.theme.selectedRow) : row,\n\t\t\t);\n\t\t}\n\n\t\t// Add scroll indicator if needed\n\t\tif (startIndex > 0 || endIndex < displayItems.length) {\n\t\t\tconst scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;\n\t\t\tlines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, \"\")));\n\t\t}\n\n\t\t// Add description for selected item\n\t\tconst selectedItem = displayItems[this.selectedIndex];\n\t\tif (selectedItem?.description) {\n\t\t\tlines.push(\"\");\n\t\t\tconst wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);\n\t\t\tfor (const line of wrappedDesc) {\n\t\t\t\tlines.push(this.theme.description(` ${line}`));\n\t\t\t}\n\t\t}\n\n\t\t// Add hint\n\t\tthis.addHintLine(lines, width);\n\n\t\treturn lines;\n\t}\n\n\thandleInput(data: string): void {\n\t\t// If submenu is active, delegate all input to it\n\t\t// The submenu's onCancel (triggered by escape) will call done() which closes it\n\t\tif (this.submenuComponent) {\n\t\t\tthis.submenuComponent.handleInput?.(data);\n\t\t\treturn;\n\t\t}\n\n\t\t// Main list input handling\n\t\tconst kb = getKeybindings();\n\t\tconst displayItems = this.searchEnabled ? this.filteredItems : this.items;\n\t\tif (kb.matches(data, \"tui.select.up\")) {\n\t\t\tif (displayItems.length === 0) return;\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;\n\t\t} else if (kb.matches(data, \"tui.select.down\")) {\n\t\t\tif (displayItems.length === 0) return;\n\t\t\tthis.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t} else if (kb.matches(data, \"tui.select.confirm\") || data === \" \") {\n\t\t\tthis.activateItem();\n\t\t} else if (kb.matches(data, \"tui.select.cancel\")) {\n\t\t\tthis.onCancel();\n\t\t} else if (this.searchEnabled && this.searchInput) {\n\t\t\tconst sanitized = data.replace(/ /g, \"\");\n\t\t\tif (!sanitized) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.searchInput.handleInput(sanitized);\n\t\t\tthis.applyFilter(this.searchInput.getValue());\n\t\t}\n\t}\n\n\tprivate activateItem(): void {\n\t\tconst item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];\n\t\tif (!item) return;\n\n\t\tif (item.submenu) {\n\t\t\t// Open submenu, passing current value so it can pre-select correctly\n\t\t\tthis.submenuItemIndex = this.selectedIndex;\n\t\t\tthis.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => {\n\t\t\t\tif (selectedValue !== undefined) {\n\t\t\t\t\titem.currentValue = selectedValue;\n\t\t\t\t\tthis.onChange(item.id, selectedValue);\n\t\t\t\t}\n\t\t\t\tthis.closeSubmenu();\n\t\t\t});\n\t\t} else if (item.values && item.values.length > 0) {\n\t\t\t// Cycle through values\n\t\t\tconst currentIndex = item.values.indexOf(item.currentValue);\n\t\t\tconst nextIndex = (currentIndex + 1) % item.values.length;\n\t\t\tconst newValue = item.values[nextIndex];\n\t\t\titem.currentValue = newValue;\n\t\t\tthis.onChange(item.id, newValue);\n\t\t}\n\t}\n\n\tprivate closeSubmenu(): void {\n\t\tthis.submenuComponent = null;\n\t\t// Restore selection to the item that opened the submenu\n\t\tif (this.submenuItemIndex !== null) {\n\t\t\tthis.selectedIndex = this.submenuItemIndex;\n\t\t\tthis.submenuItemIndex = null;\n\t\t}\n\t}\n\n\tprivate applyFilter(query: string): void {\n\t\tthis.filteredItems = fuzzyFilter(this.items, query, (item) =>\n\t\t\titem.keywords ? `${item.label} ${item.keywords}` : item.label,\n\t\t);\n\t\tthis.selectedIndex = 0;\n\t}\n\n\tprivate addHintLine(lines: string[], width: number): void {\n\t\tlines.push(\"\");\n\t\tlines.push(\n\t\t\ttruncateToWidth(\n\t\t\t\tthis.theme.hint(\n\t\t\t\t\tthis.searchEnabled\n\t\t\t\t\t\t? \" Type to search · Enter/Space to change · Esc to cancel\"\n\t\t\t\t\t\t: \" Enter/Space to change · Esc to cancel\",\n\t\t\t\t),\n\t\t\t\twidth,\n\t\t\t),\n\t\t);\n\t}\n}\n"]}
1
+ {"version":3,"file":"settings-list.d.ts","sourceRoot":"","sources":["../../src/components/settings-list.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAI3C,MAAM,WAAW,WAAW;IAC3B,yCAAyC;IACzC,EAAE,EAAE,MAAM,CAAC;IACX,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4CAA4C;IAC5C,YAAY,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,uFAAuF;IACvF,OAAO,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,aAAa,CAAC,EAAE,MAAM,KAAK,IAAI,KAAK,SAAS,CAAC;CACtF;AAED,MAAM,WAAW,iBAAiB;IACjC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,KAAK,MAAM,CAAC;IACnD,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,KAAK,MAAM,CAAC;IACnD,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;CACvC;AAED,MAAM,WAAW,mBAAmB;IACnC,YAAY,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,qBAAa,YAAa,YAAW,SAAS;IAC7C,OAAO,CAAC,KAAK,CAAgB;IAC7B,OAAO,CAAC,aAAa,CAAgB;IACrC,OAAO,CAAC,KAAK,CAAoB;IACjC,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,QAAQ,CAAyC;IACzD,OAAO,CAAC,QAAQ,CAAa;IAC7B,OAAO,CAAC,WAAW,CAAC,CAAQ;IAC5B,OAAO,CAAC,aAAa,CAAU;IAG/B,OAAO,CAAC,gBAAgB,CAA0B;IAClD,OAAO,CAAC,gBAAgB,CAAuB;IAE/C,YACC,KAAK,EAAE,WAAW,EAAE,EACpB,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,iBAAiB,EACxB,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EAChD,QAAQ,EAAE,MAAM,IAAI,EACpB,OAAO,GAAE,mBAAwB,EAYjC;IAED,oCAAoC;IACpC,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAK9C;IAED,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAO9B;IAED,OAAO,CAAC,cAAc;IAmGtB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CA6B9B;IAED,OAAO,CAAC,YAAY;IAwBpB,OAAO,CAAC,YAAY;IASpB,OAAO,CAAC,WAAW;IAOnB,OAAO,CAAC,WAAW;CAanB","sourcesContent":["import { fuzzyFilter } from \"../fuzzy.js\";\nimport { getKeybindings } from \"../keybindings.js\";\nimport type { Component } from \"../tui.js\";\nimport { applyBackgroundToLine, truncateToWidth, visibleWidth, wrapTextWithAnsi } from \"../utils.js\";\nimport { Input } from \"./input.js\";\n\nexport interface SettingItem {\n\t/** Unique identifier for this setting */\n\tid: string;\n\t/** Display label (left side) */\n\tlabel: string;\n\t/** Optional description shown when selected */\n\tdescription?: string;\n\t/** Current value to display (right side) */\n\tcurrentValue: string;\n\t/**\n\t * Display-only text rendered after the value, dimmed and never cycled. For\n\t * what a row *costs* rather than what it is set to - a token price, a count -\n\t * which would otherwise have to be folded into `currentValue`, where the next\n\t * keypress would fail to find it among `values` and reset the row.\n\t */\n\tvalueSuffix?: string;\n\t/**\n\t * Extra text the search matches against, never rendered. A row that stands for\n\t * other rows (a category) lists what is inside it here, so searching for a\n\t * setting finds the row that leads to it instead of coming back empty.\n\t */\n\tkeywords?: string;\n\t/** If provided, Enter/Space cycles through these values */\n\tvalues?: string[];\n\t/** If provided, Enter opens this submenu. Receives current value and done callback. */\n\tsubmenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;\n}\n\nexport interface SettingsListTheme {\n\tlabel: (text: string, selected: boolean) => string;\n\tvalue: (text: string, selected: boolean) => string;\n\tdescription: (text: string) => string;\n\tcursor: string;\n\thint: (text: string) => string;\n\t/**\n\t * Optional background for the selected row. When set, the row is padded out\n\t * to the full width before being painted, so the highlight reaches the right\n\t * edge instead of stopping at the value. `label` and `value` still style the\n\t * content, so the band is added to that marker rather than replacing it.\n\t * Themes that leave this undefined keep the cursor-and-accent row as-is.\n\t */\n\tselectedRow?: (text: string) => string;\n}\n\nexport interface SettingsListOptions {\n\tenableSearch?: boolean;\n}\n\nexport class SettingsList implements Component {\n\tprivate items: SettingItem[];\n\tprivate filteredItems: SettingItem[];\n\tprivate theme: SettingsListTheme;\n\tprivate selectedIndex = 0;\n\tprivate maxVisible: number;\n\tprivate onChange: (id: string, newValue: string) => void;\n\tprivate onCancel: () => void;\n\tprivate searchInput?: Input;\n\tprivate searchEnabled: boolean;\n\n\t// Submenu state\n\tprivate submenuComponent: Component | null = null;\n\tprivate submenuItemIndex: number | null = null;\n\n\tconstructor(\n\t\titems: SettingItem[],\n\t\tmaxVisible: number,\n\t\ttheme: SettingsListTheme,\n\t\tonChange: (id: string, newValue: string) => void,\n\t\tonCancel: () => void,\n\t\toptions: SettingsListOptions = {},\n\t) {\n\t\tthis.items = items;\n\t\tthis.filteredItems = items;\n\t\tthis.maxVisible = maxVisible;\n\t\tthis.theme = theme;\n\t\tthis.onChange = onChange;\n\t\tthis.onCancel = onCancel;\n\t\tthis.searchEnabled = options.enableSearch ?? false;\n\t\tif (this.searchEnabled) {\n\t\t\tthis.searchInput = new Input();\n\t\t}\n\t}\n\n\t/** Update an item's currentValue */\n\tupdateValue(id: string, newValue: string): void {\n\t\tconst item = this.items.find((i) => i.id === id);\n\t\tif (item) {\n\t\t\titem.currentValue = newValue;\n\t\t}\n\t}\n\n\tinvalidate(): void {\n\t\tthis.submenuComponent?.invalidate?.();\n\t}\n\n\trender(width: number): string[] {\n\t\t// If submenu is active, render it instead\n\t\tif (this.submenuComponent) {\n\t\t\treturn this.submenuComponent.render(width);\n\t\t}\n\n\t\treturn this.renderMainList(width);\n\t}\n\n\tprivate renderMainList(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\tif (this.searchEnabled && this.searchInput) {\n\t\t\tlines.push(...this.searchInput.render(width));\n\t\t\tlines.push(\"\");\n\t\t}\n\n\t\tif (this.items.length === 0) {\n\t\t\tlines.push(this.theme.hint(\" No settings available\"));\n\t\t\tif (this.searchEnabled) {\n\t\t\t\tthis.addHintLine(lines, width);\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\tconst displayItems = this.searchEnabled ? this.filteredItems : this.items;\n\t\tif (displayItems.length === 0) {\n\t\t\tlines.push(truncateToWidth(this.theme.hint(\" No matching settings\"), width));\n\t\t\tthis.addHintLine(lines, width);\n\t\t\treturn lines;\n\t\t}\n\n\t\t// Calculate visible range with scrolling\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);\n\n\t\t// Calculate max label width for alignment\n\t\tconst maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));\n\n\t\t// Render visible items\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst item = displayItems[i];\n\t\t\tif (!item) continue;\n\n\t\t\tconst isSelected = i === this.selectedIndex;\n\t\t\t// Indent unselected rows by the cursor's width rather than a fixed two\n\t\t\t// columns, so the label column stays put whatever cursor a theme names.\n\t\t\tconst prefixWidth = visibleWidth(this.theme.cursor);\n\t\t\tconst prefix = isSelected ? this.theme.cursor : \" \".repeat(prefixWidth);\n\n\t\t\t// Pad label to align values\n\t\t\tconst labelPadded = item.label + \" \".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));\n\t\t\tconst labelText = this.theme.label(labelPadded, isSelected);\n\n\t\t\t// Calculate space for value\n\t\t\tconst separator = \" \";\n\t\t\tconst usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);\n\t\t\t// What the label column leaves, to the last cell: the row is truncated\n\t\t\t// to `width` below and a selected row is padded out to it, so holding\n\t\t\t// two columns back only shortened the value for nothing.\n\t\t\tconst valueMaxWidth = width - usedWidth;\n\n\t\t\tconst valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, \"\"), isSelected);\n\t\t\t// The suffix takes what the value left behind, so a narrow terminal drops\n\t\t\t// the price rather than the setting.\n\t\t\tconst suffixText = item.valueSuffix\n\t\t\t\t? this.theme.hint(\n\t\t\t\t\t\ttruncateToWidth(\n\t\t\t\t\t\t\t` ${item.valueSuffix}`,\n\t\t\t\t\t\t\tMath.max(0, valueMaxWidth - visibleWidth(item.currentValue)),\n\t\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t: \"\";\n\n\t\t\tconst row = truncateToWidth(prefix + labelText + separator + valueText + suffixText, width);\n\t\t\tlines.push(\n\t\t\t\tisSelected && this.theme.selectedRow ? applyBackgroundToLine(row, width, this.theme.selectedRow) : row,\n\t\t\t);\n\t\t}\n\n\t\t// Add scroll indicator if needed\n\t\tif (startIndex > 0 || endIndex < displayItems.length) {\n\t\t\tconst scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;\n\t\t\tlines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, \"\")));\n\t\t}\n\n\t\t// Add description for selected item\n\t\tconst selectedItem = displayItems[this.selectedIndex];\n\t\tif (selectedItem?.description) {\n\t\t\tlines.push(\"\");\n\t\t\t// Two columns of indent, so two off the wrap — not four. The extra pair\n\t\t\t// was a right margin nothing needed.\n\t\t\tconst wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 2);\n\t\t\tfor (const line of wrappedDesc) {\n\t\t\t\tlines.push(this.theme.description(` ${line}`));\n\t\t\t}\n\t\t}\n\n\t\t// Add hint\n\t\tthis.addHintLine(lines, width);\n\n\t\treturn lines;\n\t}\n\n\thandleInput(data: string): void {\n\t\t// If submenu is active, delegate all input to it\n\t\t// The submenu's onCancel (triggered by escape) will call done() which closes it\n\t\tif (this.submenuComponent) {\n\t\t\tthis.submenuComponent.handleInput?.(data);\n\t\t\treturn;\n\t\t}\n\n\t\t// Main list input handling\n\t\tconst kb = getKeybindings();\n\t\tconst displayItems = this.searchEnabled ? this.filteredItems : this.items;\n\t\tif (kb.matches(data, \"tui.select.up\")) {\n\t\t\tif (displayItems.length === 0) return;\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;\n\t\t} else if (kb.matches(data, \"tui.select.down\")) {\n\t\t\tif (displayItems.length === 0) return;\n\t\t\tthis.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t} else if (kb.matches(data, \"tui.select.confirm\") || data === \" \") {\n\t\t\tthis.activateItem();\n\t\t} else if (kb.matches(data, \"tui.select.cancel\")) {\n\t\t\tthis.onCancel();\n\t\t} else if (this.searchEnabled && this.searchInput) {\n\t\t\tconst sanitized = data.replace(/ /g, \"\");\n\t\t\tif (!sanitized) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.searchInput.handleInput(sanitized);\n\t\t\tthis.applyFilter(this.searchInput.getValue());\n\t\t}\n\t}\n\n\tprivate activateItem(): void {\n\t\tconst item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];\n\t\tif (!item) return;\n\n\t\tif (item.submenu) {\n\t\t\t// Open submenu, passing current value so it can pre-select correctly\n\t\t\tthis.submenuItemIndex = this.selectedIndex;\n\t\t\tthis.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => {\n\t\t\t\tif (selectedValue !== undefined) {\n\t\t\t\t\titem.currentValue = selectedValue;\n\t\t\t\t\tthis.onChange(item.id, selectedValue);\n\t\t\t\t}\n\t\t\t\tthis.closeSubmenu();\n\t\t\t});\n\t\t} else if (item.values && item.values.length > 0) {\n\t\t\t// Cycle through values\n\t\t\tconst currentIndex = item.values.indexOf(item.currentValue);\n\t\t\tconst nextIndex = (currentIndex + 1) % item.values.length;\n\t\t\tconst newValue = item.values[nextIndex];\n\t\t\titem.currentValue = newValue;\n\t\t\tthis.onChange(item.id, newValue);\n\t\t}\n\t}\n\n\tprivate closeSubmenu(): void {\n\t\tthis.submenuComponent = null;\n\t\t// Restore selection to the item that opened the submenu\n\t\tif (this.submenuItemIndex !== null) {\n\t\t\tthis.selectedIndex = this.submenuItemIndex;\n\t\t\tthis.submenuItemIndex = null;\n\t\t}\n\t}\n\n\tprivate applyFilter(query: string): void {\n\t\tthis.filteredItems = fuzzyFilter(this.items, query, (item) =>\n\t\t\titem.keywords ? `${item.label} ${item.keywords}` : item.label,\n\t\t);\n\t\tthis.selectedIndex = 0;\n\t}\n\n\tprivate addHintLine(lines: string[], width: number): void {\n\t\tlines.push(\"\");\n\t\tlines.push(\n\t\t\ttruncateToWidth(\n\t\t\t\tthis.theme.hint(\n\t\t\t\t\tthis.searchEnabled\n\t\t\t\t\t\t? \" Type to search · Enter/Space to change · Esc to cancel\"\n\t\t\t\t\t\t: \" Enter/Space to change · Esc to cancel\",\n\t\t\t\t),\n\t\t\t\twidth,\n\t\t\t),\n\t\t);\n\t}\n}\n"]}