@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.
- package/CHANGELOG.md +7 -0
- package/dist/types/components/composer/borderless.d.ts +8 -0
- package/dist/types/components/composer/box.d.ts +2 -0
- package/dist/types/components/composer/claude.d.ts +8 -0
- package/dist/types/components/composer/field.d.ts +3 -0
- package/dist/types/components/composer/index.d.ts +9 -0
- package/dist/types/components/composer/pi.d.ts +2 -0
- package/dist/types/components/composer/rail.d.ts +3 -0
- package/dist/types/components/composer/registry.d.ts +12 -0
- package/dist/types/components/composer/rule.d.ts +7 -0
- package/dist/types/components/composer/types.d.ts +90 -0
- package/dist/types/components/editor.d.ts +10 -9
- package/dist/types/components/settings-list.d.ts +7 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/utils.d.ts +0 -1
- package/package.json +3 -3
- package/src/components/composer/borderless.ts +37 -0
- package/src/components/composer/box.ts +85 -0
- package/src/components/composer/claude.ts +39 -0
- package/src/components/composer/field.ts +47 -0
- package/src/components/composer/index.ts +9 -0
- package/src/components/composer/pi.ts +37 -0
- package/src/components/composer/rail.ts +48 -0
- package/src/components/composer/registry.ts +46 -0
- package/src/components/composer/rule.ts +55 -0
- package/src/components/composer/types.ts +98 -0
- package/src/components/editor.ts +103 -92
- package/src/components/settings-list.ts +43 -11
- package/src/index.ts +1 -0
- package/src/utils.ts +53 -58
|
@@ -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
|
+
}
|
package/src/components/editor.ts
CHANGED
|
@@ -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
|
-
|
|
729
|
-
return
|
|
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.#
|
|
772
|
+
return this.#effectiveStyle().sideChromeWidth(paddingX);
|
|
734
773
|
}
|
|
735
774
|
|
|
736
775
|
#getPromptGutterWidth(width: number, paddingX: number): number {
|
|
737
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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.#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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 (!
|
|
1025
|
+
if (!isSideBordered && displayWidth > lineContentWidth) {
|
|
995
1026
|
displayText = sliceByColumn(displayText, 0, lineContentWidth, true);
|
|
996
1027
|
displayWidth = visibleWidth(displayText);
|
|
997
1028
|
}
|
|
998
1029
|
|
|
999
|
-
if (!
|
|
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 &&
|
|
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 && !
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
|
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(` ${
|
|
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
|
|
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
|
|
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
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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 (
|
|
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
|
|
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";
|