@rosetears/aili-pi 0.1.0 → 0.1.3
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/README.md +15 -6
- package/THIRD_PARTY_NOTICES.md +14 -3
- package/extensions/header/index.ts +92 -0
- package/extensions/matrix/index.ts +375 -0
- package/extensions/zentui/config.ts +1014 -0
- package/extensions/zentui/extension-status.ts +96 -0
- package/extensions/zentui/fixed-editor/cluster.ts +98 -0
- package/extensions/zentui/fixed-editor/compositor.ts +719 -0
- package/extensions/zentui/fixed-editor/index.ts +223 -0
- package/extensions/zentui/fixed-editor/input.ts +85 -0
- package/extensions/zentui/fixed-editor/pi-compat.ts +296 -0
- package/extensions/zentui/fixed-editor/selection.ts +217 -0
- package/extensions/zentui/fixed-editor/terminal-modes.ts +75 -0
- package/extensions/zentui/fixed-editor/types.ts +37 -0
- package/extensions/zentui/footer-format.ts +279 -0
- package/extensions/zentui/footer.ts +595 -0
- package/extensions/zentui/format.ts +434 -0
- package/extensions/zentui/git.ts +384 -0
- package/extensions/zentui/gradient.ts +70 -0
- package/extensions/zentui/icons.ts +252 -0
- package/extensions/zentui/index.ts +580 -0
- package/extensions/zentui/live-context.ts +75 -0
- package/extensions/zentui/package-version.ts +650 -0
- package/extensions/zentui/project-refresh.ts +104 -0
- package/extensions/zentui/project-state.ts +59 -0
- package/extensions/zentui/prototype-patch-registry.ts +111 -0
- package/extensions/zentui/runtime.ts +841 -0
- package/extensions/zentui/selector-border.ts +77 -0
- package/extensions/zentui/session-lifecycle.ts +60 -0
- package/extensions/zentui/settings-command.ts +897 -0
- package/extensions/zentui/state.ts +55 -0
- package/extensions/zentui/style.ts +332 -0
- package/extensions/zentui/thinking-message.ts +159 -0
- package/extensions/zentui/tool-execution.ts +189 -0
- package/extensions/zentui/ui.ts +618 -0
- package/extensions/zentui/user-message.ts +252 -0
- package/licenses/pi-sakura-cyberdeck-MIT.txt +21 -0
- package/licenses/pi-zentui-MIT.txt +21 -0
- package/manifests/capabilities.json +1 -1
- package/manifests/live-verification.json +14 -8
- package/manifests/provenance.json +18 -5
- package/manifests/sbom.json +19 -3
- package/notices/pi-sakura-cyberdeck-NOTICE.txt +6 -0
- package/package.json +12 -3
- package/scripts/local-package-e2e.ts +1 -1
- package/scripts/sync-global-skills.d.mts +13 -0
- package/scripts/sync-global-skills.mjs +134 -0
- package/src/runtime/credential-guard.ts +58 -0
- package/src/runtime/doctor.ts +1 -1
- package/src/runtime/index.ts +2 -2
- package/src/runtime/path-boundaries.ts +1 -1
- package/src/runtime/registry.ts +6 -2
- package/src/runtime/rem-head.txt +38 -0
- package/src/runtime/rose-context.ts +1 -1
- package/src/runtime/subagents.ts +74 -403
- package/templates/APPEND_SYSTEM.md +10 -8
- package/themes/rem-cyberdeck.json +32 -0
- package/upstream/opencode-global-agents.lock.json +34 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag-to-select state, highlight rendering, and text extraction.
|
|
3
|
+
*
|
|
4
|
+
* The selection operates on raw transcript lines (ANSI-styled strings).
|
|
5
|
+
* Highlight uses SGR 7 (inverse video) / SGR 27 (inverse off).
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
|
|
12
|
+
/** ANSI / OSC escape sequence patterns for stripping. */
|
|
13
|
+
const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
14
|
+
const OSC_RE = /\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g;
|
|
15
|
+
|
|
16
|
+
function stripAnsi(line: string): string {
|
|
17
|
+
return line.replace(OSC_RE, "").replace(ANSI_RE, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** OSC 8 hyperlink: \x1b]8;;params ST TEXT \x1b]8;; ST
|
|
21
|
+
* Supports both ST (\x1b\\) and BEL (\x07) terminators. */
|
|
22
|
+
const OSC8_RE = /\x1b\]8;;([^\x1b\x07]*)(?:\x07|\x1b\\)([\s\S]*?)\x1b\]8;;(?:\x07|\x1b\\)/g;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Replace OSC 8 hyperlinks with their URL before stripping.
|
|
26
|
+
* Format: \x1b]8;;[key=val;]URL\x1b\\TEXT\x1b]8;;\x1b\\
|
|
27
|
+
* If the URL differs from the visible text, both are included.
|
|
28
|
+
*/
|
|
29
|
+
function extractOsc8Links(line: string): string {
|
|
30
|
+
return line.replace(OSC8_RE, (_match, params: string, text: string) => {
|
|
31
|
+
const parts = params.split(";");
|
|
32
|
+
const url = parts[parts.length - 1] ?? "";
|
|
33
|
+
const visible = stripAnsi(text);
|
|
34
|
+
if (!url) return visible;
|
|
35
|
+
if (visible && visible !== url) return `${visible} ${url}`;
|
|
36
|
+
return url;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
41
|
+
|
|
42
|
+
/** Slice text by visible column boundaries (grapheme-aware). */
|
|
43
|
+
function sliceColumns(text: string, startCol: number, endCol: number): string {
|
|
44
|
+
let col = 0;
|
|
45
|
+
let result = "";
|
|
46
|
+
for (const { segment } of graphemeSegmenter.segment(text)) {
|
|
47
|
+
const width = Math.max(0, visibleWidth(segment));
|
|
48
|
+
if (col >= startCol && col < endCol) result += segment;
|
|
49
|
+
col += width;
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function comparePoints(a: { line: number; col: number }, b: { line: number; col: number }): number {
|
|
55
|
+
return a.line === b.line ? a.col - b.col : a.line - b.line;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Track an in-progress drag selection over transcript lines. */
|
|
59
|
+
export class SelectionState {
|
|
60
|
+
private anchor: { line: number; col: number } | null = null;
|
|
61
|
+
private focus: { line: number; col: number } | null = null;
|
|
62
|
+
private dragging = false;
|
|
63
|
+
|
|
64
|
+
start(line: number, col: number): void {
|
|
65
|
+
this.anchor = { line, col };
|
|
66
|
+
this.focus = { line, col };
|
|
67
|
+
this.dragging = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
extend(line: number, col: number): void {
|
|
71
|
+
this.focus = { line, col };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
clear(): void {
|
|
75
|
+
this.anchor = null;
|
|
76
|
+
this.focus = null;
|
|
77
|
+
this.dragging = false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get active(): boolean {
|
|
81
|
+
return this.anchor !== null && this.focus !== null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get isDragging(): boolean {
|
|
85
|
+
return this.dragging;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
setDragging(value: boolean): void {
|
|
89
|
+
this.dragging = value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Get the normalized (start ≤ end) selection bounds. */
|
|
93
|
+
private get bounds(): {
|
|
94
|
+
start: { line: number; col: number };
|
|
95
|
+
end: { line: number; col: number };
|
|
96
|
+
} | null {
|
|
97
|
+
if (!this.anchor || !this.focus) return null;
|
|
98
|
+
return comparePoints(this.anchor, this.focus) <= 0
|
|
99
|
+
? { start: this.anchor, end: this.focus }
|
|
100
|
+
: { start: this.focus, end: this.anchor };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Get column range for a given line index, or null if line is not selected. */
|
|
104
|
+
getRangeForLine(lineIndex: number): { startCol: number; endCol: number } | null {
|
|
105
|
+
const b = this.bounds;
|
|
106
|
+
if (!b) return null;
|
|
107
|
+
if (lineIndex < b.start.line || lineIndex > b.end.line) return null;
|
|
108
|
+
return {
|
|
109
|
+
startCol: lineIndex === b.start.line ? b.start.col : 0,
|
|
110
|
+
endCol: lineIndex === b.end.line ? b.end.col : Number.POSITIVE_INFINITY,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Extract selected text from raw lines.
|
|
116
|
+
* @param lines Full array of transcript lines (ANSI-styled).
|
|
117
|
+
* @returns Stripped text, or "" if selection is empty.
|
|
118
|
+
*/
|
|
119
|
+
getSelectedText(lines: string[]): string {
|
|
120
|
+
const b = this.bounds;
|
|
121
|
+
if (!b) return "";
|
|
122
|
+
if (b.start.line === b.end.line && b.start.col === b.end.col) return "";
|
|
123
|
+
|
|
124
|
+
const selected: string[] = [];
|
|
125
|
+
for (let i = b.start.line; i <= b.end.line; i++) {
|
|
126
|
+
const plain = stripAnsi(extractOsc8Links(lines[i] ?? ""));
|
|
127
|
+
const startCol = i === b.start.line ? b.start.col : 0;
|
|
128
|
+
const endCol = i === b.end.line ? b.end.col : Number.POSITIVE_INFINITY;
|
|
129
|
+
selected.push(sliceColumns(plain, startCol, endCol));
|
|
130
|
+
}
|
|
131
|
+
return selected
|
|
132
|
+
.join("\n")
|
|
133
|
+
.replace(/[ \t]+$/gm, "")
|
|
134
|
+
.trimEnd();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Apply inverse-video highlight to a rendered line for the current selection.
|
|
140
|
+
* Preserves all original ANSI styling (colors, bold, etc.) — only layers
|
|
141
|
+
* a subtle background tint (SGR 48; 256-color dark gray) on top of the
|
|
142
|
+
* selected range so original foreground colors remain visible.
|
|
143
|
+
*
|
|
144
|
+
* @param line The raw ANSI-styled line.
|
|
145
|
+
* @param lineIndex The absolute transcript line index.
|
|
146
|
+
* @param selection Current selection state.
|
|
147
|
+
*/
|
|
148
|
+
export function highlightSelection(
|
|
149
|
+
line: string,
|
|
150
|
+
lineIndex: number,
|
|
151
|
+
selection: SelectionState,
|
|
152
|
+
): string {
|
|
153
|
+
const range = selection.getRangeForLine(lineIndex);
|
|
154
|
+
if (!range) return line;
|
|
155
|
+
|
|
156
|
+
const maxCol = visibleWidth(line);
|
|
157
|
+
const startCol = Math.max(0, Math.min(range.startCol, maxCol));
|
|
158
|
+
const endCol = Math.max(startCol + 1, Math.min(range.endCol, maxCol));
|
|
159
|
+
if (startCol >= endCol) return line;
|
|
160
|
+
|
|
161
|
+
let result = "";
|
|
162
|
+
let col = 0;
|
|
163
|
+
let inverseOn = false;
|
|
164
|
+
let i = 0;
|
|
165
|
+
|
|
166
|
+
while (i < line.length) {
|
|
167
|
+
// Escape sequence — pass through, does not consume visible columns.
|
|
168
|
+
if (line[i] === "\x1b") {
|
|
169
|
+
if (line[i + 1] === "[") {
|
|
170
|
+
// CSI: \x1b[...final-byte
|
|
171
|
+
let j = i + 2;
|
|
172
|
+
while (j < line.length && !/[@-~]/.test(line[j] ?? "")) j++;
|
|
173
|
+
j++;
|
|
174
|
+
result += line.slice(i, j);
|
|
175
|
+
i = j;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (line[i + 1] === "]") {
|
|
179
|
+
// OSC: \x1b]...BEL or \x1b]...ST
|
|
180
|
+
let j = i + 2;
|
|
181
|
+
while (
|
|
182
|
+
j < line.length &&
|
|
183
|
+
line[j] !== "\x07" &&
|
|
184
|
+
!(line[j] === "\x1b" && line[j + 1] === "\\")
|
|
185
|
+
)
|
|
186
|
+
j++;
|
|
187
|
+
if (line[j] === "\x07") j++;
|
|
188
|
+
else j += 2;
|
|
189
|
+
result += line.slice(i, j);
|
|
190
|
+
i = j;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// Other escape: ESC + single char
|
|
194
|
+
result += line.slice(i, i + 2);
|
|
195
|
+
i += 2;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Visible character.
|
|
200
|
+
const char = line[i] ?? "";
|
|
201
|
+
const w = visibleWidth(char);
|
|
202
|
+
if (!inverseOn && col < endCol && col + w > startCol) {
|
|
203
|
+
result += "\x1b[48;5;238m";
|
|
204
|
+
inverseOn = true;
|
|
205
|
+
}
|
|
206
|
+
if (inverseOn && col >= endCol) {
|
|
207
|
+
result += "\x1b[49m";
|
|
208
|
+
inverseOn = false;
|
|
209
|
+
}
|
|
210
|
+
result += char;
|
|
211
|
+
col += w;
|
|
212
|
+
i++;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (inverseOn) result += "\x1b[49m";
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal escape sequence constants and helpers.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Enter alternate screen buffer. */
|
|
8
|
+
export const ENTER_ALT_SCREEN = "\x1b[?1049h";
|
|
9
|
+
|
|
10
|
+
/** Exit alternate screen buffer. */
|
|
11
|
+
export const EXIT_ALT_SCREEN = "\x1b[?1049l";
|
|
12
|
+
|
|
13
|
+
/** Enable SGR mouse reporting (button-event ?1002 + SGR ?1006 encoding).
|
|
14
|
+
* Requires app-level drag-select because native terminal selection is
|
|
15
|
+
* disabled while any mouse reporting mode is active. */
|
|
16
|
+
export const ENABLE_MOUSE_SGR = "\x1b[?1002h\x1b[?1006h";
|
|
17
|
+
|
|
18
|
+
/** Disable mouse reporting (all modes we may have enabled). */
|
|
19
|
+
export const DISABLE_MOUSE = "\x1b[?1002l\x1b[?1006l\x1b[?1000l";
|
|
20
|
+
|
|
21
|
+
/** Disable alternate scroll (xterm wheel-as-arrow in alt screen). */
|
|
22
|
+
export const DISABLE_ALT_SCROLL = "\x1b[?1007l";
|
|
23
|
+
|
|
24
|
+
/** Enable alternate scroll. */
|
|
25
|
+
export const ENABLE_ALT_SCROLL = "\x1b[?1007h";
|
|
26
|
+
|
|
27
|
+
/** Reset scroll region to full screen. */
|
|
28
|
+
export const RESET_SCROLL_REGION = "\x1b[r";
|
|
29
|
+
|
|
30
|
+
/** Begin synchronized output (reduce flicker). */
|
|
31
|
+
export const SYNC_BEGIN = "\x1b[?2026h";
|
|
32
|
+
|
|
33
|
+
/** End synchronized output. */
|
|
34
|
+
export const SYNC_END = "\x1b[?2026l";
|
|
35
|
+
|
|
36
|
+
/** Hide cursor. */
|
|
37
|
+
export const HIDE_CURSOR = "\x1b[?25l";
|
|
38
|
+
|
|
39
|
+
/** Show cursor. */
|
|
40
|
+
export const SHOW_CURSOR = "\x1b[?25h";
|
|
41
|
+
|
|
42
|
+
/** Clear entire line. */
|
|
43
|
+
export const CLEAR_LINE = "\x1b[2K";
|
|
44
|
+
|
|
45
|
+
/** Disable auto-wrap (DECAWM off). */
|
|
46
|
+
export const DISABLE_AUTOWRAP = "\x1b[?7l";
|
|
47
|
+
|
|
48
|
+
/** Enable auto-wrap (DECAWM on). */
|
|
49
|
+
export const ENABLE_AUTOWRAP = "\x1b[?7h";
|
|
50
|
+
|
|
51
|
+
/** Set scroll region to rows [top, bottom] (1-indexed). */
|
|
52
|
+
export function setScrollRegion(top: number, bottom: number): string {
|
|
53
|
+
return `\x1b[${top};${bottom}r`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Move cursor to absolute position (1-indexed row, col). */
|
|
57
|
+
export function cursorTo(row: number, col: number): string {
|
|
58
|
+
return `\x1b[${row};${col}H`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Emit all sequences needed to restore the terminal to a safe state.
|
|
63
|
+
* Call on dispose and process.exit to avoid leaving the terminal broken.
|
|
64
|
+
*/
|
|
65
|
+
export function emergencyTerminalReset(): string {
|
|
66
|
+
return (
|
|
67
|
+
SYNC_BEGIN +
|
|
68
|
+
RESET_SCROLL_REGION +
|
|
69
|
+
DISABLE_MOUSE +
|
|
70
|
+
ENABLE_ALT_SCROLL +
|
|
71
|
+
EXIT_ALT_SCREEN +
|
|
72
|
+
SHOW_CURSOR +
|
|
73
|
+
SYNC_END
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the fixed/sticky editor compositor.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Result of parsing a mouse SGR wheel sequence. */
|
|
8
|
+
export type MouseScrollInput = {
|
|
9
|
+
direction: "up" | "down";
|
|
10
|
+
amount: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Full parsed SGR mouse event. */
|
|
14
|
+
export type MouseEvent = {
|
|
15
|
+
button: "left" | "middle" | "right" | "wheel-up" | "wheel-down" | "other";
|
|
16
|
+
action: "press" | "drag" | "release";
|
|
17
|
+
col: number; // 1-indexed
|
|
18
|
+
row: number; // 1-indexed
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Result of parsing a keyboard scroll sequence. */
|
|
22
|
+
export type KeyboardScrollInput = {
|
|
23
|
+
action: "pageUp" | "pageDown" | "jumpBottom" | "lineUp" | "lineDown";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Compositor configuration provided by a getter. */
|
|
27
|
+
export type CompositorConfig = {
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
mouseScroll: boolean;
|
|
30
|
+
copyNotice: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Result of rendering the pinned cluster. */
|
|
34
|
+
export type ClusterRender = {
|
|
35
|
+
lines: string[];
|
|
36
|
+
cursor: { row: number; col: number } | null;
|
|
37
|
+
};
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starship-style footer format string parser and renderer.
|
|
3
|
+
*
|
|
4
|
+
* Pure module (no TUI/config imports) so it is fully unit-testable.
|
|
5
|
+
*
|
|
6
|
+
* Supports conditional groups: `( ... )` is dropped when every nested
|
|
7
|
+
* variable (and nested group) renders empty.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type FormatToken =
|
|
11
|
+
| { kind: "text"; value: string }
|
|
12
|
+
| { kind: "var"; name: string }
|
|
13
|
+
| { kind: "fill" }
|
|
14
|
+
| { kind: "group"; tokens: FormatToken[] };
|
|
15
|
+
|
|
16
|
+
const TOKEN_REGEX = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Tokenize a format string into text/var/fill/group tokens.
|
|
20
|
+
*
|
|
21
|
+
* `$name` and `${name}` both produce a variable token. A variable named
|
|
22
|
+
* `fill` becomes a fill token instead. Parentheses form conditional groups
|
|
23
|
+
* that drop entirely when all nested vars are empty.
|
|
24
|
+
*/
|
|
25
|
+
export function parseFooterFormat(format: string): FormatToken[] {
|
|
26
|
+
if (!format) return [];
|
|
27
|
+
return parseTokenSlice(format, 0, format.length, true).tokens;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseTokenSlice(
|
|
31
|
+
format: string,
|
|
32
|
+
start: number,
|
|
33
|
+
end: number,
|
|
34
|
+
topLevel = false,
|
|
35
|
+
): { tokens: FormatToken[]; nextIndex: number } {
|
|
36
|
+
const tokens: FormatToken[] = [];
|
|
37
|
+
let index = start;
|
|
38
|
+
let textStart = start;
|
|
39
|
+
|
|
40
|
+
const flushText = (until: number) => {
|
|
41
|
+
if (until > textStart) {
|
|
42
|
+
tokens.push({ kind: "text", value: format.slice(textStart, until) });
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
while (index < end) {
|
|
47
|
+
const ch = format[index];
|
|
48
|
+
|
|
49
|
+
if (ch === "(") {
|
|
50
|
+
flushText(index);
|
|
51
|
+
const nested = parseTokenSlice(format, index + 1, end, false);
|
|
52
|
+
tokens.push({ kind: "group", tokens: nested.tokens });
|
|
53
|
+
index = nested.nextIndex;
|
|
54
|
+
textStart = index;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (ch === ")") {
|
|
59
|
+
// Nested groups close on `)`. Unmatched top-level `)` is literal text so
|
|
60
|
+
// trailing tokens like `$cwd) $tokens` are not discarded.
|
|
61
|
+
if (topLevel) {
|
|
62
|
+
index += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
flushText(index);
|
|
66
|
+
return { tokens, nextIndex: index + 1 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (ch === "$") {
|
|
70
|
+
TOKEN_REGEX.lastIndex = index;
|
|
71
|
+
const match = TOKEN_REGEX.exec(format);
|
|
72
|
+
if (match && match.index === index && match.index < end) {
|
|
73
|
+
const full = match[0];
|
|
74
|
+
const matchEnd = match.index + full.length;
|
|
75
|
+
if (matchEnd > end) {
|
|
76
|
+
index += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
flushText(index);
|
|
80
|
+
const name = match[1] ?? match[2] ?? "";
|
|
81
|
+
if (name === "fill") {
|
|
82
|
+
tokens.push({ kind: "fill" });
|
|
83
|
+
} else {
|
|
84
|
+
tokens.push({ kind: "var", name });
|
|
85
|
+
}
|
|
86
|
+
index = matchEnd;
|
|
87
|
+
textStart = index;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
index += 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
flushText(end);
|
|
96
|
+
return { tokens, nextIndex: end };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Render tokens into `{ left, middle, right }` based on `$fill` markers.
|
|
101
|
+
*
|
|
102
|
+
* - No fill: everything → `left`; `middle` and `right` are `""`.
|
|
103
|
+
* - One fill: tokens before → `left`, tokens after → `right`; `middle` is `""`.
|
|
104
|
+
* - Two fills: before the first → `left`, between the two → `middle`
|
|
105
|
+
* (centered by the caller via the existing middle-zone logic), after the
|
|
106
|
+
* second → `right`.
|
|
107
|
+
* - Additional fills beyond the first two are ignored.
|
|
108
|
+
* - `$fill` inside a group is ignored (renders empty).
|
|
109
|
+
*
|
|
110
|
+
* Text tokens contribute their `value` verbatim (unstyled/plain); var tokens
|
|
111
|
+
* contribute `renderVariable(name)` (already styled by caller). No automatic
|
|
112
|
+
* spaces are inserted — the user controls all spacing.
|
|
113
|
+
*/
|
|
114
|
+
export function renderFormatSplit(
|
|
115
|
+
tokens: FormatToken[],
|
|
116
|
+
renderVariable: (name: string) => string,
|
|
117
|
+
): { left: string; middle: string; right: string } {
|
|
118
|
+
const fillIndices = findTopLevelFillIndices(tokens);
|
|
119
|
+
|
|
120
|
+
if (fillIndices.length === 0) {
|
|
121
|
+
return {
|
|
122
|
+
left: renderTokenSlice(tokens, 0, tokens.length, renderVariable),
|
|
123
|
+
middle: "",
|
|
124
|
+
right: "",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const first = fillIndices[0];
|
|
129
|
+
const second = fillIndices[1];
|
|
130
|
+
|
|
131
|
+
if (first === undefined) {
|
|
132
|
+
return {
|
|
133
|
+
left: renderTokenSlice(tokens, 0, tokens.length, renderVariable),
|
|
134
|
+
middle: "",
|
|
135
|
+
right: "",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (second === undefined) {
|
|
140
|
+
return {
|
|
141
|
+
left: renderTokenSlice(tokens, 0, first, renderVariable),
|
|
142
|
+
middle: "",
|
|
143
|
+
right: renderTokenSlice(tokens, first + 1, tokens.length, renderVariable),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
left: renderTokenSlice(tokens, 0, first, renderVariable),
|
|
149
|
+
middle: renderTokenSlice(tokens, first + 1, second, renderVariable),
|
|
150
|
+
right: renderTokenSlice(tokens, second + 1, tokens.length, renderVariable),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function findTopLevelFillIndices(tokens: FormatToken[]): number[] {
|
|
155
|
+
const fillIndices: number[] = [];
|
|
156
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
157
|
+
if (tokens[index]?.kind === "fill") fillIndices.push(index);
|
|
158
|
+
}
|
|
159
|
+
return fillIndices;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderTokenSlice(
|
|
163
|
+
tokens: FormatToken[],
|
|
164
|
+
start: number,
|
|
165
|
+
end: number,
|
|
166
|
+
renderVariable: (name: string) => string,
|
|
167
|
+
): string {
|
|
168
|
+
let result = "";
|
|
169
|
+
for (let i = start; i < end; i++) {
|
|
170
|
+
const token = tokens[i];
|
|
171
|
+
if (!token) continue;
|
|
172
|
+
result += renderToken(token, renderVariable);
|
|
173
|
+
}
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function renderToken(token: FormatToken, renderVariable: (name: string) => string): string {
|
|
178
|
+
if (token.kind === "text") return token.value;
|
|
179
|
+
if (token.kind === "var") return renderVariable(token.name);
|
|
180
|
+
if (token.kind === "fill") return "";
|
|
181
|
+
// group
|
|
182
|
+
const rendered = token.tokens.map((child) => renderToken(child, renderVariable)).join("");
|
|
183
|
+
if (isGroupEmpty(token, renderVariable)) return "";
|
|
184
|
+
return rendered;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Separator vars only style gaps between content; they must not keep a group
|
|
189
|
+
* alive when every real content var is empty (e.g. `($sep$tokens)` drops if
|
|
190
|
+
* tokens is empty).
|
|
191
|
+
*/
|
|
192
|
+
const NON_CONTENT_VARS = new Set(["sep", "separator"]);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A group is empty iff every content var leaf is empty and every nested group
|
|
196
|
+
* is empty. Text-only groups (no vars) always show. `$sep` / `$separator` are
|
|
197
|
+
* ignored for emptiness so orphan themed pipes do not force a group to render.
|
|
198
|
+
*/
|
|
199
|
+
function isGroupEmpty(
|
|
200
|
+
group: FormatToken & { kind: "group" },
|
|
201
|
+
renderVariable: (name: string) => string,
|
|
202
|
+
): boolean {
|
|
203
|
+
let sawContentVarOrGroup = false;
|
|
204
|
+
for (const child of group.tokens) {
|
|
205
|
+
if (child.kind === "var") {
|
|
206
|
+
if (NON_CONTENT_VARS.has(child.name)) continue;
|
|
207
|
+
sawContentVarOrGroup = true;
|
|
208
|
+
if (renderVariable(child.name) !== "") return false;
|
|
209
|
+
} else if (child.kind === "group") {
|
|
210
|
+
sawContentVarOrGroup = true;
|
|
211
|
+
if (!isGroupEmpty(child, renderVariable)) return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Text-only groups (or groups with only $sep) are shown only when no content vars.
|
|
215
|
+
// Groups that only contain $sep still count as empty so they drop.
|
|
216
|
+
return sawContentVarOrGroup || groupOnlyNonContentVars(group);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function groupOnlyNonContentVars(group: FormatToken & { kind: "group" }): boolean {
|
|
220
|
+
let sawSep = false;
|
|
221
|
+
for (const child of group.tokens) {
|
|
222
|
+
if (child.kind === "text") {
|
|
223
|
+
if (child.value.trim() !== "") return false;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (child.kind === "var") {
|
|
227
|
+
if (!NON_CONTENT_VARS.has(child.name)) return false;
|
|
228
|
+
sawSep = true;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (child.kind === "group") return false;
|
|
232
|
+
if (child.kind === "fill") continue;
|
|
233
|
+
}
|
|
234
|
+
return sawSep;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** One optional SGR sequence (`\x1b[…m`). */
|
|
238
|
+
const ANSI_ONE_SRC = "\u001b\\[[0-9;]*m";
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* One ` | ` separator unit, plain or with a single ANSI wrapper on either side
|
|
242
|
+
* of the spaces/pipe (matches `renderStyle(..., " | ")` output).
|
|
243
|
+
*/
|
|
244
|
+
const SEP_UNIT_SRC = `(?:${ANSI_ONE_SRC})?\\s+\\|\\s+(?:${ANSI_ONE_SRC})?`;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Join non-empty parts with a separator (segment-mode style).
|
|
248
|
+
* Useful when building right-side metrics without orphan pipes.
|
|
249
|
+
*/
|
|
250
|
+
export function joinNonEmpty(parts: string[], separator: string): string {
|
|
251
|
+
return parts.filter(Boolean).join(separator);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Tidy a rendered format left/middle/right slice:
|
|
256
|
+
* - collapse repeated pipe separators (plain or simple ANSI-wrapped) into one
|
|
257
|
+
* - strip leading/trailing pipe separators
|
|
258
|
+
* - strip leading/trailing whitespace
|
|
259
|
+
* - drop slices that are only ANSI / whitespace after cleanup
|
|
260
|
+
*/
|
|
261
|
+
export function stripOrphanSeparators(rendered: string): string {
|
|
262
|
+
if (!rendered) return rendered;
|
|
263
|
+
|
|
264
|
+
// Collapse consecutive separator units, keeping the first (preserves themed color).
|
|
265
|
+
const consecutive = new RegExp(`(${SEP_UNIT_SRC})(?:${SEP_UNIT_SRC})+`, "g");
|
|
266
|
+
let result = rendered.replace(consecutive, "$1");
|
|
267
|
+
|
|
268
|
+
// Strip leading / trailing separator units.
|
|
269
|
+
result = result.replace(new RegExp(`^(?:${SEP_UNIT_SRC})+`), "");
|
|
270
|
+
result = result.replace(new RegExp(`(?:${SEP_UNIT_SRC})+$`), "");
|
|
271
|
+
|
|
272
|
+
// Strip leading / trailing plain whitespace left by empty groups.
|
|
273
|
+
result = result.replace(/^\s+/, "").replace(/\s+$/, "");
|
|
274
|
+
|
|
275
|
+
// Pure ANSI (or empty) leftovers are not useful content.
|
|
276
|
+
if (result.replace(new RegExp(ANSI_ONE_SRC, "g"), "").trim() === "") return "";
|
|
277
|
+
|
|
278
|
+
return result;
|
|
279
|
+
}
|