@oh-my-pi/pi-tui 17.3.7 → 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.
Files changed (33) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/types/components/composer/borderless.d.ts +8 -0
  3. package/dist/types/components/composer/box.d.ts +2 -0
  4. package/dist/types/components/composer/claude.d.ts +8 -0
  5. package/dist/types/components/composer/field.d.ts +3 -0
  6. package/dist/types/components/composer/index.d.ts +9 -0
  7. package/dist/types/components/composer/pi.d.ts +2 -0
  8. package/dist/types/components/composer/rail.d.ts +3 -0
  9. package/dist/types/components/composer/registry.d.ts +12 -0
  10. package/dist/types/components/composer/rule.d.ts +7 -0
  11. package/dist/types/components/composer/types.d.ts +90 -0
  12. package/dist/types/components/editor.d.ts +10 -9
  13. package/dist/types/components/settings-list.d.ts +7 -1
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/terminal-capabilities.d.ts +7 -0
  16. package/dist/types/utils.d.ts +0 -1
  17. package/package.json +3 -3
  18. package/src/components/composer/borderless.ts +37 -0
  19. package/src/components/composer/box.ts +85 -0
  20. package/src/components/composer/claude.ts +39 -0
  21. package/src/components/composer/field.ts +47 -0
  22. package/src/components/composer/index.ts +9 -0
  23. package/src/components/composer/pi.ts +37 -0
  24. package/src/components/composer/rail.ts +48 -0
  25. package/src/components/composer/registry.ts +46 -0
  26. package/src/components/composer/rule.ts +55 -0
  27. package/src/components/composer/types.ts +98 -0
  28. package/src/components/editor.ts +115 -99
  29. package/src/components/settings-list.ts +43 -11
  30. package/src/index.ts +1 -0
  31. package/src/terminal-capabilities.ts +10 -0
  32. package/src/tui.ts +53 -56
  33. package/src/utils.ts +53 -58
@@ -0,0 +1,46 @@
1
+ import { borderlessComposerStyle } from "./borderless";
2
+ import { boxComposerStyle } from "./box";
3
+ import { claudeComposerStyle } from "./claude";
4
+ import { fieldComposerStyle } from "./field";
5
+ import { piComposerStyle } from "./pi";
6
+ import { railComposerStyle } from "./rail";
7
+ import { ruleComposerStyle } from "./rule";
8
+ import type { ComposerStyle, EditorBorderStyle } from "./types";
9
+
10
+ const BUILTIN_COMPOSER_STYLES: Readonly<Record<string, ComposerStyle>> = {
11
+ box: boxComposerStyle,
12
+ claude: claudeComposerStyle,
13
+ pi: piComposerStyle,
14
+ borderless: borderlessComposerStyle,
15
+ rule: ruleComposerStyle,
16
+ field: fieldComposerStyle,
17
+ rail: railComposerStyle,
18
+ };
19
+ const extensionComposerStyles = new Map<string, ComposerStyle>();
20
+
21
+ /** Whether an id names a composer style shipped by pi-tui. */
22
+ export function isBuiltinComposerStyle(id: string): boolean {
23
+ return Object.hasOwn(BUILTIN_COMPOSER_STYLES, id);
24
+ }
25
+
26
+ /**
27
+ * Register one extension-owned composer style for this process.
28
+ *
29
+ * Built-in ids and duplicate extension ids are rejected. The returned disposer
30
+ * removes only this registration.
31
+ */
32
+ export function registerComposerStyle(style: ComposerStyle): () => void {
33
+ const id = style.id.trim();
34
+ if (id.length === 0 || id !== style.id) throw new TypeError("Composer style id must be a non-empty trimmed string");
35
+ if (isBuiltinComposerStyle(id)) throw new Error(`Cannot replace built-in composer style "${id}"`);
36
+ if (extensionComposerStyles.has(id)) throw new Error(`Composer style "${id}" is already registered`);
37
+ extensionComposerStyles.set(id, style);
38
+ return () => {
39
+ if (extensionComposerStyles.get(id) === style) extensionComposerStyles.delete(id);
40
+ };
41
+ }
42
+
43
+ /** Style object for a composer shape; unknown ids fall back to `box`. */
44
+ export function getComposerStyle(id: EditorBorderStyle): ComposerStyle {
45
+ return extensionComposerStyles.get(id) ?? BUILTIN_COMPOSER_STYLES[id] ?? boxComposerStyle;
46
+ }
@@ -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);
@@ -1458,14 +1469,19 @@ export class Editor implements Component, Focusable {
1458
1469
  this.#addNewLine();
1459
1470
  }
1460
1471
  }
1461
- // New line
1472
+ // New line. A key the user explicitly bound to `tui.input.submit` wins
1473
+ // over these hardcoded newline fallbacks, so Ctrl/Shift+Enter can be
1474
+ // remapped to submit (#8906). The bare-LF case is exempt: its canonical
1475
+ // form is "enter" (indistinguishable from plain Enter), so gating it
1476
+ // would hijack the default Enter=submit binding.
1462
1477
  else if (
1463
- (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
1464
- matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
1465
- data === "\x1b\r" || // Option+Enter in some terminals (legacy)
1466
- data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
1467
- kb.matchesCanonical(canonical, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
1468
- (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
1478
+ (!kb.matchesCanonical(canonical, "tui.input.submit") &&
1479
+ ((data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
1480
+ matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
1481
+ data === "\x1b\r" || // Option+Enter in some terminals (legacy)
1482
+ data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
1483
+ kb.matchesCanonical(canonical, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
1484
+ (data.length > 1 && data.includes("\x1b") && data.includes("\r")))) ||
1469
1485
  (data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
1470
1486
  ) {
1471
1487
  if (this.#shouldSubmitOnBackslashEnter(data, kb)) {