@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.
- package/CHANGELOG.md +14 -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/terminal-capabilities.d.ts +7 -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 +115 -99
- package/src/components/settings-list.ts +43 -11
- package/src/index.ts +1 -0
- package/src/terminal-capabilities.ts +10 -0
- package/src/tui.ts +53 -56
- 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
|
+
}
|
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);
|
|
@@ -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
|
-
(
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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)) {
|