@oh-my-pi/pi-tui 17.3.8 → 17.4.0

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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Single-rule composer: a borderless prompt docked below one top rule. The
3
+ * right status group rides the rule while the left group remains below the
4
+ * editor, preserving the compact status split without a closing rule.
5
+ */
6
+ import { truncateToWidth, visibleWidth } from "../../utils";
7
+ import type { ComposerChromeContext, ComposerRowContext, ComposerStyle } from "./types";
8
+
9
+ /** Draw a full-width rule with status content docked at its right edge.
10
+ * Over-wide content is truncated (keeping one rule cell on each side) rather
11
+ * than dropped, so the chip survives narrow terminals and previews. */
12
+ export function renderTopRule(ctx: ComposerChromeContext): string {
13
+ const { box, width, borderColor, topBorder } = ctx;
14
+ if (topBorder && topBorder.width > 0 && width > 2) {
15
+ let { content, width: chipWidth } = topBorder;
16
+ if (chipWidth > width - 2) {
17
+ content = truncateToWidth(content, width - 2);
18
+ chipWidth = visibleWidth(content);
19
+ }
20
+ const leftFill = Math.max(0, width - chipWidth - 1);
21
+ return borderColor(box.horizontal.repeat(leftFill)) + content + borderColor(box.horizontal);
22
+ }
23
+ return borderColor(box.horizontal.repeat(width));
24
+ }
25
+
26
+ /** Composer style with one status-bearing top rule and no bottom chrome. */
27
+ export const ruleComposerStyle: ComposerStyle = {
28
+ id: "rule",
29
+ sideBorders: false,
30
+ verticalChrome: 1,
31
+ statusAttachment: "top-rule-chip",
32
+ bottomBar: "left",
33
+ bottomBarGap: true,
34
+ defaultPromptGutter: "❯ ",
35
+
36
+ defaultPaddingX(): number {
37
+ return 0;
38
+ },
39
+
40
+ sideChromeWidth(paddingX: number): number {
41
+ return paddingX;
42
+ },
43
+
44
+ renderTop(ctx: ComposerChromeContext): string {
45
+ return renderTopRule(ctx);
46
+ },
47
+
48
+ renderRow(ctx: ComposerRowContext): string[] {
49
+ return [ctx.gutter + ctx.text + ctx.pad];
50
+ },
51
+
52
+ renderBottom(): undefined {
53
+ return undefined;
54
+ },
55
+ };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Composer chrome contract. A {@link ComposerStyle} owns everything about how
3
+ * the input editor's frame looks — top/bottom chrome, per-row side chrome,
4
+ * default padding and prompt gutter — plus metadata telling the host where the
5
+ * status bar attaches. The editor, the /settings preview, and the setup-wizard
6
+ * preview all render through the same style object, so the three surfaces can
7
+ * never drift apart.
8
+ */
9
+ import type { SymbolTheme } from "../../symbols";
10
+
11
+ /** Box-drawing glyph set used for composer chrome (the theme's `boxRound`). */
12
+ export type ComposerBox = SymbolTheme["boxRound"];
13
+
14
+ /** Built-in composer shape identifiers shipped by pi-tui. */
15
+ export const BUILTIN_EDITOR_BORDER_STYLES = ["box", "claude", "pi", "borderless", "rule", "field", "rail"] as const;
16
+
17
+ /** Identifier for a built-in composer shape. */
18
+ export type BuiltinEditorBorderStyle = (typeof BUILTIN_EDITOR_BORDER_STYLES)[number];
19
+
20
+ /** Composer shape identifier; extensions may register additional strings. */
21
+ export type EditorBorderStyle = string;
22
+
23
+ /** Pre-rendered status content injected into the top chrome. */
24
+ export interface EditorTopBorder {
25
+ /** The status content (already styled) */
26
+ content: string;
27
+ /** Visible width of the content */
28
+ width: number;
29
+ /** Optional logical revision that changes independently of available width. */
30
+ revision?: number;
31
+ }
32
+
33
+ /** Inputs shared by every chrome row. */
34
+ export interface ComposerChromeContext {
35
+ /** Full terminal width available to the composer. */
36
+ width: number;
37
+ /** Horizontal padding inside the side chrome. */
38
+ paddingX: number;
39
+ borderColor: (str: string) => string;
40
+ /** Stable accent used by shape-defining chrome such as field caps and rails. */
41
+ accentColor: (str: string) => string;
42
+ /** Background fill for composer surfaces; preserves the fill across nested SGR resets. */
43
+ surfaceColor: (str: string) => string;
44
+ /** Box-drawing glyph set (theme's `boxRound`). */
45
+ box: ComposerBox;
46
+ /** Status content for the top chrome; box embeds it after the corner while
47
+ * rule-based styles dock it against the right edge. */
48
+ topBorder?: EditorTopBorder;
49
+ }
50
+
51
+ /** Inputs for one content row. */
52
+ export interface ComposerRowContext extends ComposerChromeContext {
53
+ /** Fully decorated row text (cursor glyph / IME marker included). */
54
+ text: string;
55
+ /** Spaces padding the text out to the content width. */
56
+ pad: string;
57
+ /** Prompt gutter cells for this row ("" when the style has none). */
58
+ gutter: string;
59
+ isLastRow: boolean;
60
+ /** Cells the end-of-line cursor overflowed into the right chrome (box). */
61
+ cursorOverflow: number;
62
+ /** Emit an empty right chrome after the cursor so terminal-local IME
63
+ * preedit cannot shift the frame (box last row). */
64
+ imeSafeCursorTail: boolean;
65
+ /** Row lies inside the right-border scrollbar thumb (box). */
66
+ scrollbarThumb: boolean;
67
+ }
68
+
69
+ export interface ComposerStyle {
70
+ readonly id: EditorBorderStyle;
71
+ /** Content rows carry left/right border glyphs; drives the cursor-reserve
72
+ * column, IME-safe layout, and the right-border scrollbar. */
73
+ readonly sideBorders: boolean;
74
+ /** Rows consumed by top+bottom chrome (drives maxHeight budgeting). */
75
+ readonly verticalChrome: 0 | 1 | 2;
76
+ /** Where the host should attach the status bar: embedded in the top border,
77
+ * docked onto a top rule, or detached into a standalone bottom bar. */
78
+ readonly statusAttachment: "top-border" | "top-rule-chip" | "none";
79
+ /** Which segment groups the standalone bottom status bar shows. */
80
+ readonly bottomBar: "none" | "left" | "full";
81
+ /** Insert a blank spacer row between the editor and the standalone bottom
82
+ * bar. Styles without bottom chrome need it so the bar doesn't sit flush
83
+ * against the last input row. */
84
+ readonly bottomBarGap: boolean;
85
+ /** Default prompt gutter when the host sets none. */
86
+ readonly defaultPromptGutter: string | undefined;
87
+ /** Default horizontal padding; `themePaddingX` is the theme's request. */
88
+ defaultPaddingX(themePaddingX: number | undefined): number;
89
+ /** Cells consumed per side on content rows (border glyph + padding). */
90
+ sideChromeWidth(paddingX: number): number;
91
+ /** Top chrome row; `undefined` renders none. */
92
+ renderTop(ctx: ComposerChromeContext): string | undefined;
93
+ /** Chrome-wrapped content row; box's IME-safe last row emits two rows. */
94
+ renderRow(ctx: ComposerRowContext): string[];
95
+ /** Bottom chrome row; `undefined` renders none (box merges the bottom
96
+ * border into its last content row). */
97
+ renderBottom(ctx: ComposerChromeContext): string | undefined;
98
+ }
@@ -23,8 +23,21 @@ import {
23
23
  truncateToWidth,
24
24
  visibleWidth,
25
25
  } from "../utils";
26
+ import {
27
+ borderlessComposerStyle,
28
+ type ComposerChromeContext,
29
+ type ComposerStyle,
30
+ type EditorBorderStyle,
31
+ type EditorTopBorder,
32
+ getComposerStyle,
33
+ } from "./composer";
34
+
35
+ export type { EditorBorderStyle, EditorTopBorder };
36
+
26
37
  import { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
27
38
 
39
+ const PASSTHROUGH_COLOR = (text: string): string => text;
40
+
28
41
  const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
29
42
  overflowSearch: false,
30
43
  };
@@ -379,6 +392,10 @@ interface WrapEntry {
379
392
 
380
393
  export interface EditorTheme {
381
394
  borderColor: (str: string) => string;
395
+ /** Stable accent for composer chrome that should not follow the mutable border state. */
396
+ accentColor?: (str: string) => string;
397
+ /** Background fill used by filled composer styles. */
398
+ surfaceColor?: (str: string) => string;
382
399
  selectList: SelectListTheme;
383
400
  symbols: SymbolTheme;
384
401
  editorPaddingX?: number;
@@ -386,15 +403,6 @@ export interface EditorTheme {
386
403
  hintStyle?: (text: string) => string;
387
404
  }
388
405
 
389
- export interface EditorTopBorder {
390
- /** The status content (already styled) */
391
- content: string;
392
- /** Visible width of the content */
393
- width: number;
394
- /** Optional logical revision that changes independently of available width. */
395
- revision?: number;
396
- }
397
-
398
406
  interface HistoryEntry {
399
407
  prompt: string;
400
408
  }
@@ -523,7 +531,7 @@ export class Editor implements Component, Focusable {
523
531
  #topBorderProviderSignature: string | undefined;
524
532
  #topBorderProviderRevision: number | undefined;
525
533
  #borderVisible = true;
526
-
534
+ #borderStyle: EditorBorderStyle = "box";
527
535
  constructor(theme: EditorTheme) {
528
536
  this.#theme = theme;
529
537
  this.borderColor = theme.borderColor;
@@ -581,6 +589,20 @@ export class Editor implements Component, Focusable {
581
589
  setPromptGutter(promptGutter: string | undefined): void {
582
590
  this.#promptGutter = promptGutter;
583
591
  }
592
+ getBorderStyle(): EditorBorderStyle {
593
+ return this.#borderStyle;
594
+ }
595
+
596
+ setBorderStyle(style: EditorBorderStyle): void {
597
+ if (this.#borderStyle === style) return;
598
+ this.#borderStyle = style;
599
+ this.#widthEpochRevision++;
600
+ }
601
+
602
+ /** True while the autocomplete/slash-command menu is open below the editor. */
603
+ isAutocompleteActive(): boolean {
604
+ return this.#autocompleteState !== null;
605
+ }
584
606
 
585
607
  /**
586
608
  * Get the available width for top border content given a total terminal width.
@@ -724,31 +746,50 @@ export class Editor implements Component, Focusable {
724
746
  // No cached state to invalidate currently
725
747
  }
726
748
 
749
+ /** Active chrome style; a hidden border collapses every shape to borderless. */
750
+ #effectiveStyle(): ComposerStyle {
751
+ return this.#borderVisible ? getComposerStyle(this.#borderStyle) : borderlessComposerStyle;
752
+ }
753
+
754
+ #getEffectivePromptGutter(): string | undefined {
755
+ const style = this.#effectiveStyle();
756
+ // The box frame never renders a gutter; hosts that set one expect it only
757
+ // in borderless contexts (hook editors, agents hub).
758
+ if (style.sideBorders) return undefined;
759
+ if (this.#promptGutter !== undefined) return this.#promptGutter;
760
+ // Legacy `setBorderVisible(false)` callers control the gutter themselves;
761
+ // only an explicitly selected composer shape gets the style default.
762
+ if (!this.#borderVisible) return undefined;
763
+ return style.defaultPromptGutter;
764
+ }
765
+
727
766
  #getEditorPaddingX(): number {
728
- const padding = this.#paddingXOverride ?? this.#theme.editorPaddingX ?? 2;
729
- return Math.max(0, padding);
767
+ if (this.#paddingXOverride !== undefined) return Math.max(0, this.#paddingXOverride);
768
+ return this.#effectiveStyle().defaultPaddingX(this.#theme.editorPaddingX);
730
769
  }
731
770
 
732
771
  #getHorizontalChromeWidth(paddingX: number): number {
733
- return this.#borderVisible ? paddingX + 1 : 0;
772
+ return this.#effectiveStyle().sideChromeWidth(paddingX);
734
773
  }
735
774
 
736
775
  #getPromptGutterWidth(width: number, paddingX: number): number {
737
- if (this.#borderVisible || !this.#promptGutter) return 0;
776
+ const gutter = this.#getEffectivePromptGutter();
777
+ if (!gutter) return 0;
738
778
  const chromeWidth = 2 * this.#getHorizontalChromeWidth(paddingX);
739
779
  const availableWidth = Math.max(0, width - chromeWidth);
740
- return Math.min(visibleWidth(this.#promptGutter), availableWidth);
780
+ return Math.min(visibleWidth(gutter), availableWidth);
741
781
  }
742
782
 
743
783
  #getPromptGutter(
744
784
  width: number,
745
785
  paddingX: number,
746
786
  ): { firstLine: string; continuation: string; width: number } | undefined {
747
- if (this.#borderVisible || !this.#promptGutter) return undefined;
787
+ const gutter = this.#getEffectivePromptGutter();
788
+ if (!gutter) return undefined;
748
789
  const gutterWidth = this.#getPromptGutterWidth(width, paddingX);
749
790
  if (gutterWidth === 0) return undefined;
750
791
  return {
751
- firstLine: sliceByColumn(this.#promptGutter, 0, gutterWidth, true),
792
+ firstLine: sliceByColumn(gutter, 0, gutterWidth, true),
752
793
  continuation: padding(gutterWidth),
753
794
  width: gutterWidth,
754
795
  };
@@ -761,17 +802,15 @@ export class Editor implements Component, Focusable {
761
802
 
762
803
  #getLayoutWidth(width: number, paddingX: number): number {
763
804
  const contentWidth = this.#getContentWidth(width, paddingX);
764
- const cursorReserve = this.#borderVisible && paddingX === 0 ? 1 : 0;
805
+ const cursorReserve = this.#effectiveStyle().sideBorders && paddingX === 0 ? 1 : 0;
765
806
  // Keep cursor/scroll layout addressable even when a borderless prompt gutter consumes every visible column.
766
807
  return Math.max(1, contentWidth - cursorReserve);
767
808
  }
768
809
 
769
810
  #getVisibleContentHeight(contentLines: number): number {
770
811
  if (this.#maxHeight === undefined) return contentLines;
771
- const verticalChrome = this.#borderVisible ? 2 : 0;
772
- return Math.max(1, this.#maxHeight - verticalChrome);
812
+ return Math.max(1, this.#maxHeight - this.#effectiveStyle().verticalChrome);
773
813
  }
774
-
775
814
  /** Apply the optional input decorator to a plain (ANSI-free) text segment.
776
815
  * Decoration only adds zero-width SGR codes, so visible width is unchanged.
777
816
  * Splits around CURSOR_MARKER so each user-text segment is decorated in
@@ -882,20 +921,16 @@ export class Editor implements Component, Focusable {
882
921
  }
883
922
 
884
923
  render(width: number): readonly string[] {
924
+ const style = this.#effectiveStyle();
885
925
  const paddingX = this.#getEditorPaddingX();
886
- const borderVisible = this.#borderVisible;
926
+ const isSideBordered = style.sideBorders;
887
927
  const promptGutter = this.#getPromptGutter(width, paddingX);
888
928
  const contentAreaWidth = this.#getContentWidth(width, paddingX);
889
929
  const layoutWidth = this.#getLayoutWidth(width, paddingX);
890
930
  this.#lastLayoutWidth = layoutWidth;
891
931
 
892
- // Box-drawing characters for rounded corners
893
932
  const box = this.#theme.symbols.boxRound;
894
933
  const borderWidth = this.#getHorizontalChromeWidth(paddingX);
895
- const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`);
896
- const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`);
897
- const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${padding(Math.max(0, paddingX - 1))}`);
898
- const horizontal = this.borderColor(box.horizontal);
899
934
 
900
935
  // Layout the text
901
936
  const layoutLines = this.#layoutText(layoutWidth);
@@ -921,13 +956,12 @@ export class Editor implements Component, Focusable {
921
956
  scrollbarThumb = { start, end: start + thumbSize };
922
957
  }
923
958
 
924
- if (borderVisible) {
925
- // Render top border: ╭─ [status content] ────────────────╮
926
- const topFillWidth = Math.max(0, width - borderWidth * 2);
927
- // Provider (lazy) wins over eager content — a host that installs both
928
- // wants the coalesced path; falling back to eager keeps existing
929
- // setTopBorder callers working unchanged.
930
- let topBorder: EditorTopBorder | undefined;
959
+ // Resolve the custom top-border content once per frame; the style decides
960
+ // how (and whether) to draw it. Provider caching stays editor-owned so
961
+ // per-event rebuilds keep coalescing to one per painted frame.
962
+ const topFillWidth = Math.max(0, width - borderWidth * 2);
963
+ let topBorder: EditorTopBorder | undefined;
964
+ if (style.statusAttachment !== "none") {
931
965
  if (this.#topBorderProvider) {
932
966
  const previousWidth = this.#topBorderProviderWidth;
933
967
  topBorder = this.#topBorderProvider(topFillWidth);
@@ -948,24 +982,21 @@ export class Editor implements Component, Focusable {
948
982
  } else {
949
983
  topBorder = this.#topBorderContent;
950
984
  }
951
- if (topBorder) {
952
- const { content, width: statusWidth } = topBorder;
953
- if (statusWidth <= topFillWidth) {
954
- // Status fits - add fill after it
955
- const fillWidth = topFillWidth - statusWidth;
956
- result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
957
- } else {
958
- // Status too long - truncate it
959
- const truncated = truncateToWidth(content, Math.max(0, topFillWidth - 1));
960
- const truncatedWidth = visibleWidth(truncated);
961
- const fillWidth = Math.max(0, topFillWidth - truncatedWidth);
962
- result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
963
- }
964
- } else {
965
- result.push(topLeft + horizontal.repeat(topFillWidth) + topRight);
966
- }
967
985
  }
968
986
 
987
+ const chromeCtx: ComposerChromeContext = {
988
+ width,
989
+ paddingX,
990
+ borderColor: (str: string) => this.borderColor(str),
991
+ accentColor: this.#theme.accentColor ?? this.borderColor,
992
+ surfaceColor: this.#theme.surfaceColor ?? PASSTHROUGH_COLOR,
993
+ box,
994
+ topBorder,
995
+ };
996
+
997
+ const topRow = style.renderTop(chromeCtx);
998
+ if (topRow !== undefined) result.push(topRow);
999
+
969
1000
  // Render each layout line
970
1001
  // Keep the hardware cursor at the text insertion point while autocomplete
971
1002
  // rows render below it; terminals use that position to anchor IME candidates.
@@ -991,12 +1022,12 @@ export class Editor implements Component, Focusable {
991
1022
  const hasCursor = layoutLine.hasCursor && layoutLine.cursorPos !== undefined;
992
1023
  const marker = emitCursorMarker ? CURSOR_MARKER : "";
993
1024
 
994
- if (!borderVisible && displayWidth > lineContentWidth) {
1025
+ if (!isSideBordered && displayWidth > lineContentWidth) {
995
1026
  displayText = sliceByColumn(displayText, 0, lineContentWidth, true);
996
1027
  displayWidth = visibleWidth(displayText);
997
1028
  }
998
1029
 
999
- if (!borderVisible && lineContentWidth === 0) {
1030
+ if (!isSideBordered && lineContentWidth === 0) {
1000
1031
  if (hasCursor && !this.#useTerminalCursor) {
1001
1032
  const zeroWidthCursorBudget = visibleWidth(gutterText);
1002
1033
  const zeroWidthCursorReplacement = this.cursorOverride
@@ -1039,7 +1070,7 @@ export class Editor implements Component, Focusable {
1039
1070
  if (marker) {
1040
1071
  const before = displayText.slice(0, layoutLine.cursorPos);
1041
1072
  const after = displayText.slice(layoutLine.cursorPos);
1042
- if (this.#imeSafeCursorLayout && after.length === 0 && borderVisible) {
1073
+ if (this.#imeSafeCursorLayout && after.length === 0 && isSideBordered) {
1043
1074
  // Terminal frontends render IME marked text locally before committed bytes
1044
1075
  // reach the application. Keep the end-of-input cursor row empty to its
1045
1076
  // right so that insertion cannot shift box chrome onto the next row.
@@ -1050,7 +1081,7 @@ export class Editor implements Component, Focusable {
1050
1081
  const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
1051
1082
  displayText = before + marker + hintText;
1052
1083
  displayWidth += Math.min(visibleWidth(inlineHint), availWidth);
1053
- } else if (after.length === 0 && !borderVisible && displayWidth >= lineContentWidth) {
1084
+ } else if (after.length === 0 && !isSideBordered && displayWidth >= lineContentWidth) {
1054
1085
  displayText = this.#renderTerminalCursorMarker(before, marker, lineContentWidth);
1055
1086
  } else {
1056
1087
  displayText = before + marker + after;
@@ -1076,7 +1107,7 @@ export class Editor implements Component, Focusable {
1076
1107
  } else if (this.cursorOverride) {
1077
1108
  // Cursor override replaces the normal end-of-text cursor glyph
1078
1109
  const overrideWidth = this.cursorOverrideWidth ?? 1;
1079
- if (!borderVisible && displayWidth + overrideWidth > lineContentWidth) {
1110
+ if (!isSideBordered && displayWidth + overrideWidth > lineContentWidth) {
1080
1111
  // Borderless editors have no spare padding cell for an end-of-line cursor glyph.
1081
1112
  // Preserve cursorOverride by replacing the tail of the line with it.
1082
1113
  const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(before, marker, lineContentWidth, {
@@ -1097,7 +1128,7 @@ export class Editor implements Component, Focusable {
1097
1128
  } else {
1098
1129
  // Cursor is at the end - add thin cursor glyph
1099
1130
  const { text: cursor, width: cursorWidth } = this.#getStyledInputCursor();
1100
- if (!borderVisible && displayWidth + cursorWidth > lineContentWidth) {
1131
+ if (!isSideBordered && displayWidth + cursorWidth > lineContentWidth) {
1101
1132
  // Borderless editors have no spare padding cell for an end-of-line cursor glyph.
1102
1133
  // Highlight the last grapheme so the cursor stays visible without consuming width.
1103
1134
  const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(before, marker, lineContentWidth);
@@ -1136,44 +1167,24 @@ export class Editor implements Component, Focusable {
1136
1167
 
1137
1168
  const linePad = padding(Math.max(0, lineContentWidth - displayWidth));
1138
1169
 
1139
- if (!borderVisible) {
1140
- result.push(gutterText + displayText + linePad);
1141
- continue;
1142
- }
1143
-
1144
- // All lines have consistent borders based on padding. When the end-of-line cursor
1145
- // glyph (or a wide trailing grapheme) extends past `lineContentWidth`, shrink the
1146
- // right chrome by the exact overflow count: drop padding spaces first, then the
1147
- // trailing `─`, but never the corner/vertical bar itself.
1148
- const isLastLine = visibleIndex === visibleLayoutLines.length - 1;
1149
- const rightChromeCells = Math.max(1, paddingX + 1 - cursorPaddingOverflow);
1150
- if (isLastLine && imeSafeCursorTail) {
1151
- const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
1152
- const bottomBorder = this.borderColor(
1153
- `${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`,
1154
- );
1155
- result.push(leftBorder + displayText);
1156
- result.push(bottomBorder);
1157
- continue;
1158
- }
1159
- if (isLastLine) {
1160
- const rightPad = Math.max(0, rightChromeCells - 2);
1161
- const includeHorizontal = rightChromeCells >= 2;
1162
- const bottomRightAdjusted = this.borderColor(
1163
- `${padding(rightPad)}${includeHorizontal ? box.horizontal : ""}${box.bottomRight}`,
1164
- );
1165
- result.push(`${bottomLeft}${displayText}${linePad}${bottomRightAdjusted}`);
1166
- } else {
1167
- const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
1168
- // When scrollbar is active, replace the right border vertical with a
1169
- // thumb glyph (█) on lines inside the thumb range, keeping the track (│) elsewhere.
1170
- const inThumb = scrollbarThumb && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end;
1171
- const rightGlyph = inThumb ? "█" : box.vertical;
1172
- const rightBorder = this.borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`);
1173
- result.push(leftBorder + displayText + linePad + rightBorder);
1174
- }
1170
+ result.push(
1171
+ ...style.renderRow({
1172
+ ...chromeCtx,
1173
+ text: displayText,
1174
+ pad: linePad,
1175
+ gutter: gutterText,
1176
+ isLastRow: visibleIndex === visibleLayoutLines.length - 1,
1177
+ cursorOverflow: cursorPaddingOverflow,
1178
+ imeSafeCursorTail,
1179
+ scrollbarThumb:
1180
+ scrollbarThumb !== null && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end,
1181
+ }),
1182
+ );
1175
1183
  }
1176
1184
 
1185
+ const bottomRow = style.renderBottom(chromeCtx);
1186
+ if (bottomRow !== undefined) result.push(bottomRow);
1187
+
1177
1188
  // Add autocomplete list if active
1178
1189
  if (this.#autocompleteState && this.#autocompleteList) {
1179
1190
  const autocompleteResult = this.#autocompleteList.render(width);
@@ -20,6 +20,8 @@ export interface SettingItem {
20
20
  label: string;
21
21
  /** Optional description shown when selected */
22
22
  description?: string;
23
+ /** Optional risk note shown in warning styling above the description, with a glyph on the row. */
24
+ warning?: string;
23
25
  /** Current value to display (right side) */
24
26
  currentValue: string;
25
27
  /** If provided, Enter/Space cycles through these values */
@@ -36,6 +38,10 @@ export interface SettingsListTheme {
36
38
  label: (text: string, selected: boolean, changed: boolean) => string;
37
39
  value: (text: string, selected: boolean, changed: boolean) => string;
38
40
  description: (text: string) => string;
41
+ /** Style for risk notes and the row warning glyph. Falls back to `description` when omitted. */
42
+ warning?: (text: string) => string;
43
+ /** Glyph marking rows that carry a `warning`. Omitted hides the row marker. */
44
+ warningMark?: string;
39
45
  cursor: string;
40
46
  hint: (text: string) => string;
41
47
  /** Style for section heading rows (dimmed when outside the active section). Falls back to `hint` when omitted. */
@@ -78,12 +84,15 @@ export interface SettingsListOptions {
78
84
  sidebarWidth?: number;
79
85
  }
80
86
 
81
- /** Searchable text for a setting item: label, id, value, description, and cycle values. */
87
+ /** Searchable text for a setting item: label, id, value, description, warning, and cycle values. */
82
88
  export function getSettingItemFilterText(item: SettingItem): string {
83
89
  let text = `${item.label} ${item.id} ${item.currentValue}`;
84
90
  if (item.description) {
85
91
  text += ` ${item.description}`;
86
92
  }
93
+ if (item.warning) {
94
+ text += ` ${item.warning}`;
95
+ }
87
96
  if (item.values) {
88
97
  text += ` ${item.values.join(" ")}`;
89
98
  }
@@ -477,6 +486,11 @@ export class SettingsList implements Component {
477
486
  return this.#padLines(this.#renderMainList(width));
478
487
  }
479
488
 
489
+ /** Warning glyph suffix for a row that carries a risk note, or "" when none applies. */
490
+ #warningMark(item: SettingItem): string {
491
+ return item.warning && this.#theme.warningMark ? ` ${this.#theme.warningMark}` : "";
492
+ }
493
+
480
494
  #renderItemRow(
481
495
  item: SettingItem,
482
496
  index: number,
@@ -495,7 +509,9 @@ export class SettingsList implements Component {
495
509
  const isSelected = index === this.#selectedIndex && !this.#sectionFocus;
496
510
  const prefix = isSelected ? this.#theme.cursor : " ";
497
511
  const prefixWidth = visibleWidth(prefix);
498
- const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
512
+ const mark = this.#warningMark(item);
513
+ const labelPlain = item.label + mark;
514
+ const labelPad = padding(Math.max(0, maxLabelWidth - visibleWidth(labelPlain)));
499
515
  const separator = " ";
500
516
  const valueMaxWidth = rowWidth - prefixWidth - maxLabelWidth - visibleWidth(separator) - 2;
501
517
  const valuePlain = truncateToWidth(String(item.currentValue ?? ""), valueMaxWidth, Ellipsis.Omit);
@@ -504,11 +520,13 @@ export class SettingsList implements Component {
504
520
  // under one dim wash so inner label/value colors don't fight it.
505
521
  if (dimmed && !isSelected) {
506
522
  const text = this.#theme.hint(
507
- truncateToWidth(` ${labelPadded}${separator}${valuePlain}`, Math.max(0, rowWidth)),
523
+ truncateToWidth(` ${labelPlain}${labelPad}${separator}${valuePlain}`, Math.max(0, rowWidth)),
508
524
  );
509
525
  return hovered && this.#theme.hovered ? this.#theme.hovered(text) : text;
510
526
  }
511
- const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true);
527
+ const warningStyle = this.#theme.warning ?? this.#theme.description;
528
+ const labelText =
529
+ this.#theme.label(item.label, isSelected, item.changed === true) + (mark ? warningStyle(mark) : "") + labelPad;
512
530
  const valueText = this.#theme.value(valuePlain, isSelected, item.changed === true);
513
531
  const text = truncateToWidth(prefix + labelText + separator + valueText, Math.max(0, rowWidth));
514
532
  // Pointer hover paints a band behind the whole row, distinct from the
@@ -550,7 +568,9 @@ export class SettingsList implements Component {
550
568
  0,
551
569
  Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight),
552
570
  );
553
- const labelWidths = this.#filteredItems.filter(item => !item.heading).map(item => visibleWidth(item.label));
571
+ const labelWidths = this.#filteredItems
572
+ .filter(item => !item.heading)
573
+ .map(item => visibleWidth(item.label + this.#warningMark(item)));
554
574
  const maxLabelWidth = Math.min(30, labelWidths.length > 0 ? Math.max(...labelWidths) : 0);
555
575
  const itemRowsOverflow = this.#filteredItems.length > viewportHeight;
556
576
  const itemRowWidth = Math.max(0, width - (itemRowsOverflow ? 1 : 0));
@@ -589,15 +609,25 @@ export class SettingsList implements Component {
589
609
 
590
610
  // Description area: 1 blank + exactly 3 rows, clamped with an ellipsis,
591
611
  // so moving between items with/without descriptions never shifts rows.
612
+ // The risk note leads so it survives the clamp when both are present.
592
613
  lines.push("");
593
614
  const selectedItem = this.#filteredItems[this.#selectedIndex];
594
615
  const descLines: string[] = [];
595
- if (selectedItem?.description && !selectedItem.heading) {
596
- const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
597
- for (const line of wrappedDesc.slice(0, 3)) {
598
- descLines.push(this.#theme.description(` ${line}`));
616
+ if (selectedItem && !selectedItem.heading) {
617
+ if (selectedItem.warning) {
618
+ const warningStyle = this.#theme.warning ?? this.#theme.description;
619
+ const mark = this.#theme.warningMark ? `${this.#theme.warningMark} ` : "";
620
+ for (const line of wrapTextWithAnsi(`${mark}${selectedItem.warning}`, width - 4)) {
621
+ descLines.push(warningStyle(` ${line}`));
622
+ }
623
+ }
624
+ if (selectedItem.description) {
625
+ for (const line of wrapTextWithAnsi(selectedItem.description, width - 4)) {
626
+ descLines.push(this.#theme.description(` ${line}`));
627
+ }
599
628
  }
600
- if (wrappedDesc.length > 3) {
629
+ if (descLines.length > 3) {
630
+ descLines.splice(3);
601
631
  descLines[2] = truncateToWidth(`${descLines[2]}…`, width);
602
632
  }
603
633
  }
@@ -659,7 +689,9 @@ export class SettingsList implements Component {
659
689
  Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight),
660
690
  );
661
691
  // Label column width spans all items so the layout stays stable across sections.
662
- const labelWidths = this.#filteredItems.filter(item => !item.heading).map(item => visibleWidth(item.label));
692
+ const labelWidths = this.#filteredItems
693
+ .filter(item => !item.heading)
694
+ .map(item => visibleWidth(item.label + this.#warningMark(item)));
663
695
  const maxLabelWidth = Math.min(30, labelWidths.length > 0 ? Math.max(...labelWidths) : 0);
664
696
  const overflow = this.#filteredItems.length > viewportHeight;
665
697
  const rowWidth = Math.max(0, paneWidth - (overflow ? 1 : 0));
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./autocomplete";
5
5
  // Components
6
6
  export * from "./components/box";
7
7
  export * from "./components/cancellable-loader";
8
+ export * from "./components/composer";
8
9
  export * from "./components/editor";
9
10
  export * from "./components/image";
10
11
  export * from "./components/input";