pi-one-ui 0.1.1
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/LICENSE +21 -0
- package/README.md +106 -0
- package/extensions/app/config/renderer.ts +452 -0
- package/extensions/app/config/shell.ts +2484 -0
- package/extensions/app/ownership.ts +18 -0
- package/extensions/app/panel.ts +533 -0
- package/extensions/app/presets.ts +36 -0
- package/extensions/features/agent-summary/core.ts +194 -0
- package/extensions/features/agent-summary/index.ts +37 -0
- package/extensions/features/compact-thinking.ts +1262 -0
- package/extensions/features/context.ts +857 -0
- package/extensions/features/reference/index.ts +643 -0
- package/extensions/features/reference/session.ts +278 -0
- package/extensions/features/reference/subagent.ts +218 -0
- package/extensions/features/shell/aliases.ts +28 -0
- package/extensions/features/shell/flush-docked-bash.ts +39 -0
- package/extensions/features/shell/startup-header.ts +357 -0
- package/extensions/features/shell/working-message.ts +226 -0
- package/extensions/index.ts +52 -0
- package/extensions/shell/accent-rail-editor.ts +112 -0
- package/extensions/shell/accent-rail-layout-patch.ts +530 -0
- package/extensions/shell/completion-menu.ts +186 -0
- package/extensions/shell/editor-metadata-format.ts +287 -0
- package/extensions/shell/editor-transfer.ts +73 -0
- package/extensions/shell/extension-status.ts +102 -0
- package/extensions/shell/footer-format.ts +388 -0
- package/extensions/shell/footer-layout.ts +144 -0
- package/extensions/shell/footer.ts +1035 -0
- package/extensions/shell/format.ts +577 -0
- package/extensions/shell/git.ts +434 -0
- package/extensions/shell/icons.ts +261 -0
- package/extensions/shell/index.ts +1659 -0
- package/extensions/shell/interaction-summary.ts +1194 -0
- package/extensions/shell/live-context.ts +82 -0
- package/extensions/shell/minimalist-editor.ts +548 -0
- package/extensions/shell/package-version.ts +675 -0
- package/extensions/shell/project-refresh.ts +130 -0
- package/extensions/shell/project-state.ts +60 -0
- package/extensions/shell/prototype-patch-registry.ts +183 -0
- package/extensions/shell/runtime.ts +915 -0
- package/extensions/shell/selector-border.ts +109 -0
- package/extensions/shell/session-lifecycle.ts +60 -0
- package/extensions/shell/settings-command.ts +2144 -0
- package/extensions/shell/settings-previews.ts +137 -0
- package/extensions/shell/state.ts +83 -0
- package/extensions/shell/style.ts +480 -0
- package/extensions/shell/telemetry.ts +90 -0
- package/extensions/shell/ui.ts +1326 -0
- package/extensions/shell/user-message-osc.ts +279 -0
- package/extensions/shell/user-message-styles.ts +266 -0
- package/extensions/shell/user-message.ts +226 -0
- package/extensions/shell/working-line-messages.ts +24 -0
- package/extensions/shell/working-line-spinners.ts +54 -0
- package/extensions/shell/working-line.ts +1603 -0
- package/extensions/tools/ansi-color.ts +44 -0
- package/extensions/tools/ansi-text.ts +52 -0
- package/extensions/tools/component-tree.ts +37 -0
- package/extensions/tools/format.ts +28 -0
- package/extensions/tools/fullscreen-detect.ts +11 -0
- package/extensions/tools/patch-keys.ts +158 -0
- package/extensions/tools/sgr-mouse.ts +59 -0
- package/extensions/tools/tool-loading-icon.ts +20 -0
- package/extensions/tools/tool-result-sanitize.ts +27 -0
- package/extensions/transcript/index.ts +65 -0
- package/extensions/transcript/renderer/compact-mode.ts +1443 -0
- package/extensions/transcript/renderer/default-mode.ts +735 -0
- package/extensions/transcript/renderer/index.ts +286 -0
- package/extensions/transcript/renderer/markdown-enhance.ts +325 -0
- package/extensions/transcript/renderer/mouse/hover.ts +175 -0
- package/extensions/transcript/renderer/mouse/interaction.ts +1016 -0
- package/extensions/transcript/renderer/mouse/layout.ts +207 -0
- package/extensions/transcript/renderer/mouse/packets.ts +96 -0
- package/extensions/transcript/renderer/mouse/scroll.ts +211 -0
- package/extensions/transcript/renderer/tool/diff/ATTRIBUTION.md +28 -0
- package/extensions/transcript/renderer/tool/diff/ansi-utils.ts +128 -0
- package/extensions/transcript/renderer/tool/diff/diff-component.ts +112 -0
- package/extensions/transcript/renderer/tool/diff/diff-edit-render.ts +255 -0
- package/extensions/transcript/renderer/tool/diff/diff-header.ts +184 -0
- package/extensions/transcript/renderer/tool/diff/diff-highlight.ts +131 -0
- package/extensions/transcript/renderer/tool/diff/diff-inline.ts +350 -0
- package/extensions/transcript/renderer/tool/diff/diff-layout.ts +870 -0
- package/extensions/transcript/renderer/tool/diff/diff-limits.ts +189 -0
- package/extensions/transcript/renderer/tool/diff/diff-palette.ts +436 -0
- package/extensions/transcript/renderer/tool/diff/diff-parse.ts +536 -0
- package/extensions/transcript/renderer/tool/diff/diff-presentation.ts +79 -0
- package/extensions/transcript/renderer/tool/diff/diff-renderer.ts +29 -0
- package/extensions/transcript/renderer/tool/diff/diff-text.ts +57 -0
- package/extensions/transcript/renderer/tool/diff/diff-write-render.ts +629 -0
- package/extensions/transcript/renderer/tool/diff/index.ts +147 -0
- package/extensions/transcript/renderer/tool/diff/line-width-safety.ts +100 -0
- package/extensions/transcript/renderer/tool/diff/shiki-highlight.ts +104 -0
- package/extensions/transcript/renderer/tool/diff/write-execution.ts +124 -0
- package/extensions/transcript/renderer/tool/grouping.ts +619 -0
- package/extensions/transcript/renderer/tool/message-display.ts +183 -0
- package/extensions/transcript/renderer/tool/names.ts +195 -0
- package/extensions/transcript/renderer/tool/result.ts +842 -0
- package/extensions/transcript/renderer/tool/show-more-hint.ts +42 -0
- package/extensions/transcript/renderer/transcript-refresh.ts +79 -0
- package/package.json +58 -0
- package/themes/cc-dark.json +91 -0
- package/themes/cc-light.json +91 -0
|
@@ -0,0 +1,388 @@
|
|
|
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
|
+
export type CompactBoundaryKind = "space" | "separator";
|
|
17
|
+
|
|
18
|
+
export type CompactFormatChunk =
|
|
19
|
+
| { kind: "tokens"; tokens: FormatToken[]; boundary: CompactBoundaryKind }
|
|
20
|
+
| { kind: "extensions"; boundary: CompactBoundaryKind };
|
|
21
|
+
|
|
22
|
+
const TOKEN_REGEX =
|
|
23
|
+
/\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Tokenize a format string into text/var/fill/group tokens.
|
|
27
|
+
*
|
|
28
|
+
* `$name` and `${name}` both produce a variable token. A variable named
|
|
29
|
+
* `fill` becomes a fill token instead. Parentheses form conditional groups
|
|
30
|
+
* that drop entirely when all nested vars are empty.
|
|
31
|
+
*/
|
|
32
|
+
export function parseFooterFormat(format: string): FormatToken[] {
|
|
33
|
+
if (!format) return [];
|
|
34
|
+
return parseTokenSlice(format, 0, format.length, true).tokens;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseTokenSlice(
|
|
38
|
+
format: string,
|
|
39
|
+
start: number,
|
|
40
|
+
end: number,
|
|
41
|
+
topLevel = false,
|
|
42
|
+
): { tokens: FormatToken[]; nextIndex: number } {
|
|
43
|
+
const tokens: FormatToken[] = [];
|
|
44
|
+
let index = start;
|
|
45
|
+
let textStart = start;
|
|
46
|
+
|
|
47
|
+
const flushText = (until: number) => {
|
|
48
|
+
if (until > textStart) {
|
|
49
|
+
tokens.push({ kind: "text", value: format.slice(textStart, until) });
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
while (index < end) {
|
|
54
|
+
const ch = format[index];
|
|
55
|
+
|
|
56
|
+
if (ch === "(") {
|
|
57
|
+
flushText(index);
|
|
58
|
+
const nested = parseTokenSlice(format, index + 1, end, false);
|
|
59
|
+
tokens.push({ kind: "group", tokens: nested.tokens });
|
|
60
|
+
index = nested.nextIndex;
|
|
61
|
+
textStart = index;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (ch === ")") {
|
|
66
|
+
// Nested groups close on `)`. Unmatched top-level `)` is literal text so
|
|
67
|
+
// trailing tokens like `$cwd) $tokens` are not discarded.
|
|
68
|
+
if (topLevel) {
|
|
69
|
+
index += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
flushText(index);
|
|
73
|
+
return { tokens, nextIndex: index + 1 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (ch === "$") {
|
|
77
|
+
TOKEN_REGEX.lastIndex = index;
|
|
78
|
+
const match = TOKEN_REGEX.exec(format);
|
|
79
|
+
if (match && match.index === index && match.index < end) {
|
|
80
|
+
const full = match[0];
|
|
81
|
+
const matchEnd = match.index + full.length;
|
|
82
|
+
if (matchEnd > end) {
|
|
83
|
+
index += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
flushText(index);
|
|
87
|
+
const name = match[1] ?? match[2] ?? "";
|
|
88
|
+
if (name === "fill") {
|
|
89
|
+
tokens.push({ kind: "fill" });
|
|
90
|
+
} else {
|
|
91
|
+
tokens.push({ kind: "var", name });
|
|
92
|
+
}
|
|
93
|
+
index = matchEnd;
|
|
94
|
+
textStart = index;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
index += 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
flushText(end);
|
|
103
|
+
return { tokens, nextIndex: end };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Render tokens into `{ left, middle, right }` based on `$fill` markers.
|
|
108
|
+
*
|
|
109
|
+
* - No fill: everything → `left`; `middle` and `right` are `""`.
|
|
110
|
+
* - One fill: tokens before → `left`, tokens after → `right`; `middle` is `""`.
|
|
111
|
+
* - Two fills: before the first → `left`, between the two → `middle`
|
|
112
|
+
* (centered by the caller via the existing middle-zone logic), after the
|
|
113
|
+
* second → `right`.
|
|
114
|
+
* - Additional fills beyond the first two are ignored.
|
|
115
|
+
* - `$fill` inside a group is ignored (renders empty).
|
|
116
|
+
*
|
|
117
|
+
* Text tokens contribute their `value` verbatim (unstyled/plain); var tokens
|
|
118
|
+
* contribute `renderVariable(name)` (already styled by caller). No automatic
|
|
119
|
+
* spaces are inserted — the user controls all spacing.
|
|
120
|
+
*/
|
|
121
|
+
export function renderFormatSplit(
|
|
122
|
+
tokens: FormatToken[],
|
|
123
|
+
renderVariable: (name: string) => string,
|
|
124
|
+
): { left: string; middle: string; right: string } {
|
|
125
|
+
const fillIndices = findTopLevelFillIndices(tokens);
|
|
126
|
+
|
|
127
|
+
if (fillIndices.length === 0) {
|
|
128
|
+
return {
|
|
129
|
+
left: renderTokenSlice(tokens, 0, tokens.length, renderVariable),
|
|
130
|
+
middle: "",
|
|
131
|
+
right: "",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const first = fillIndices[0];
|
|
136
|
+
const second = fillIndices[1];
|
|
137
|
+
|
|
138
|
+
if (first === undefined) {
|
|
139
|
+
return {
|
|
140
|
+
left: renderTokenSlice(tokens, 0, tokens.length, renderVariable),
|
|
141
|
+
middle: "",
|
|
142
|
+
right: "",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (second === undefined) {
|
|
147
|
+
return {
|
|
148
|
+
left: renderTokenSlice(tokens, 0, first, renderVariable),
|
|
149
|
+
middle: "",
|
|
150
|
+
right: renderTokenSlice(tokens, first + 1, tokens.length, renderVariable),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
left: renderTokenSlice(tokens, 0, first, renderVariable),
|
|
156
|
+
middle: renderTokenSlice(tokens, first + 1, second, renderVariable),
|
|
157
|
+
right: renderTokenSlice(tokens, second + 1, tokens.length, renderVariable),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function compileCompactFormat(
|
|
162
|
+
tokens: FormatToken[],
|
|
163
|
+
): CompactFormatChunk[] {
|
|
164
|
+
const chunks: CompactFormatChunk[] = [];
|
|
165
|
+
let current: FormatToken[] = [];
|
|
166
|
+
let incomingBoundary: CompactBoundaryKind = "space";
|
|
167
|
+
|
|
168
|
+
const flush = () => {
|
|
169
|
+
const normalized = trimBoundaryWhitespace(current);
|
|
170
|
+
current = [];
|
|
171
|
+
if (normalized.length === 0) return;
|
|
172
|
+
if (
|
|
173
|
+
normalized.length === 1 &&
|
|
174
|
+
normalized[0]?.kind === "var" &&
|
|
175
|
+
normalized[0].name === "extensions"
|
|
176
|
+
) {
|
|
177
|
+
chunks.push({ kind: "extensions", boundary: incomingBoundary });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
chunks.push({
|
|
181
|
+
kind: "tokens",
|
|
182
|
+
tokens: normalized,
|
|
183
|
+
boundary: incomingBoundary,
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
for (const token of tokens) {
|
|
188
|
+
if (
|
|
189
|
+
token.kind === "var" &&
|
|
190
|
+
(token.name === "wrap" || token.name === "wrap_sep")
|
|
191
|
+
) {
|
|
192
|
+
flush();
|
|
193
|
+
incomingBoundary = token.name === "wrap_sep" ? "separator" : "space";
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (token.kind === "fill") continue;
|
|
197
|
+
current.push(token);
|
|
198
|
+
}
|
|
199
|
+
flush();
|
|
200
|
+
return chunks;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function trimBoundaryWhitespace(tokens: FormatToken[]): FormatToken[] {
|
|
204
|
+
const result = tokens.map((token) =>
|
|
205
|
+
token.kind === "text" ? { ...token } : token,
|
|
206
|
+
);
|
|
207
|
+
while (result[0]?.kind === "text") {
|
|
208
|
+
result[0].value = result[0].value.replace(/^\s+/, "");
|
|
209
|
+
if (result[0].value) break;
|
|
210
|
+
result.shift();
|
|
211
|
+
}
|
|
212
|
+
while (result.at(-1)?.kind === "text") {
|
|
213
|
+
const last = result.at(-1);
|
|
214
|
+
if (last?.kind !== "text") break;
|
|
215
|
+
last.value = last.value.replace(/\s+$/, "");
|
|
216
|
+
if (last.value) break;
|
|
217
|
+
result.pop();
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function renderFormatTokens(
|
|
223
|
+
tokens: FormatToken[],
|
|
224
|
+
renderVariable: (name: string) => string,
|
|
225
|
+
): string {
|
|
226
|
+
return renderTokenSlice(tokens, 0, tokens.length, renderVariable);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function collectFooterFormatReferences(
|
|
230
|
+
tokens: FormatToken[],
|
|
231
|
+
aliases: Record<string, string> = {},
|
|
232
|
+
): Set<string> {
|
|
233
|
+
const references = new Set<string>();
|
|
234
|
+
const visit = (items: FormatToken[]) => {
|
|
235
|
+
for (const token of items) {
|
|
236
|
+
if (token.kind === "group") {
|
|
237
|
+
visit(token.tokens);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (token.kind !== "var") continue;
|
|
241
|
+
const canonical = aliases[token.name] ?? token.name;
|
|
242
|
+
if (
|
|
243
|
+
canonical !== "wrap" &&
|
|
244
|
+
canonical !== "wrap_sep" &&
|
|
245
|
+
canonical !== "extensions"
|
|
246
|
+
) {
|
|
247
|
+
references.add(canonical);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
visit(tokens);
|
|
252
|
+
return references;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function findTopLevelFillIndices(tokens: FormatToken[]): number[] {
|
|
256
|
+
const fillIndices: number[] = [];
|
|
257
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
258
|
+
if (tokens[index]?.kind === "fill") fillIndices.push(index);
|
|
259
|
+
}
|
|
260
|
+
return fillIndices;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function renderTokenSlice(
|
|
264
|
+
tokens: FormatToken[],
|
|
265
|
+
start: number,
|
|
266
|
+
end: number,
|
|
267
|
+
renderVariable: (name: string) => string,
|
|
268
|
+
): string {
|
|
269
|
+
let result = "";
|
|
270
|
+
for (let i = start; i < end; i++) {
|
|
271
|
+
const token = tokens[i];
|
|
272
|
+
if (!token) continue;
|
|
273
|
+
result += renderToken(token, renderVariable);
|
|
274
|
+
}
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function renderToken(
|
|
279
|
+
token: FormatToken,
|
|
280
|
+
renderVariable: (name: string) => string,
|
|
281
|
+
): string {
|
|
282
|
+
if (token.kind === "text") return token.value;
|
|
283
|
+
if (token.kind === "var") return renderVariable(token.name);
|
|
284
|
+
if (token.kind === "fill") return "";
|
|
285
|
+
// group
|
|
286
|
+
const rendered = token.tokens
|
|
287
|
+
.map((child) => renderToken(child, renderVariable))
|
|
288
|
+
.join("");
|
|
289
|
+
if (isGroupEmpty(token, renderVariable)) return "";
|
|
290
|
+
return rendered;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Separator vars only style gaps between content; they must not keep a group
|
|
295
|
+
* alive when every real content var is empty (e.g. `($sep$tokens)` drops if
|
|
296
|
+
* tokens is empty).
|
|
297
|
+
*/
|
|
298
|
+
const NON_CONTENT_VARS = new Set(["sep", "separator"]);
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A group is empty iff every content var leaf is empty and every nested group
|
|
302
|
+
* is empty. Text-only groups (no vars) always show. `$sep` / `$separator` are
|
|
303
|
+
* ignored for emptiness so orphan themed pipes do not force a group to render.
|
|
304
|
+
*/
|
|
305
|
+
function isGroupEmpty(
|
|
306
|
+
group: FormatToken & { kind: "group" },
|
|
307
|
+
renderVariable: (name: string) => string,
|
|
308
|
+
): boolean {
|
|
309
|
+
let sawContentVarOrGroup = false;
|
|
310
|
+
for (const child of group.tokens) {
|
|
311
|
+
if (child.kind === "var") {
|
|
312
|
+
if (NON_CONTENT_VARS.has(child.name)) continue;
|
|
313
|
+
sawContentVarOrGroup = true;
|
|
314
|
+
if (renderVariable(child.name) !== "") return false;
|
|
315
|
+
} else if (child.kind === "group") {
|
|
316
|
+
sawContentVarOrGroup = true;
|
|
317
|
+
if (!isGroupEmpty(child, renderVariable)) return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Text-only groups (or groups with only $sep) are shown only when no content vars.
|
|
321
|
+
// Groups that only contain $sep still count as empty so they drop.
|
|
322
|
+
return sawContentVarOrGroup || groupOnlyNonContentVars(group);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function groupOnlyNonContentVars(
|
|
326
|
+
group: FormatToken & { kind: "group" },
|
|
327
|
+
): boolean {
|
|
328
|
+
let sawSep = false;
|
|
329
|
+
for (const child of group.tokens) {
|
|
330
|
+
if (child.kind === "text") {
|
|
331
|
+
if (child.value.trim() !== "") return false;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (child.kind === "var") {
|
|
335
|
+
if (!NON_CONTENT_VARS.has(child.name)) return false;
|
|
336
|
+
sawSep = true;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (child.kind === "group") return false;
|
|
340
|
+
if (child.kind === "fill") continue;
|
|
341
|
+
}
|
|
342
|
+
return sawSep;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** One optional SGR sequence (`\x1b[…m`). */
|
|
346
|
+
const ANSI_ONE_SRC = "\u001b\\[[0-9;]*m";
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* One ` | ` separator unit, plain or with a single ANSI wrapper on either side
|
|
350
|
+
* of the spaces/pipe (matches `renderStyle(..., " | ")` output).
|
|
351
|
+
*/
|
|
352
|
+
const SEP_UNIT_SRC = `(?:${ANSI_ONE_SRC})?\\s+\\|\\s+(?:${ANSI_ONE_SRC})?`;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Join non-empty parts with a separator (segment-mode style).
|
|
356
|
+
* Useful when building right-side metrics without orphan pipes.
|
|
357
|
+
*/
|
|
358
|
+
export function joinNonEmpty(parts: string[], separator: string): string {
|
|
359
|
+
return parts.filter(Boolean).join(separator);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Tidy a rendered format left/middle/right slice:
|
|
364
|
+
* - collapse repeated pipe separators (plain or simple ANSI-wrapped) into one
|
|
365
|
+
* - strip leading/trailing pipe separators
|
|
366
|
+
* - strip leading/trailing whitespace
|
|
367
|
+
* - drop slices that are only ANSI / whitespace after cleanup
|
|
368
|
+
*/
|
|
369
|
+
export function stripOrphanSeparators(rendered: string): string {
|
|
370
|
+
if (!rendered) return rendered;
|
|
371
|
+
|
|
372
|
+
// Collapse consecutive separator units, keeping the first (preserves themed color).
|
|
373
|
+
const consecutive = new RegExp(`(${SEP_UNIT_SRC})(?:${SEP_UNIT_SRC})+`, "g");
|
|
374
|
+
let result = rendered.replace(consecutive, "$1");
|
|
375
|
+
|
|
376
|
+
// Strip leading / trailing separator units.
|
|
377
|
+
result = result.replace(new RegExp(`^(?:${SEP_UNIT_SRC})+`), "");
|
|
378
|
+
result = result.replace(new RegExp(`(?:${SEP_UNIT_SRC})+$`), "");
|
|
379
|
+
|
|
380
|
+
// Strip leading / trailing plain whitespace left by empty groups.
|
|
381
|
+
result = result.replace(/^\s+/, "").replace(/\s+$/, "");
|
|
382
|
+
|
|
383
|
+
// Pure ANSI (or empty) leftovers are not useful content.
|
|
384
|
+
if (result.replace(new RegExp(ANSI_ONE_SRC, "g"), "").trim() === "")
|
|
385
|
+
return "";
|
|
386
|
+
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { CompactFooterMaxLines } from "../app/config/shell.ts";
|
|
3
|
+
import type { CompactBoundaryKind } from "./footer-format.ts";
|
|
4
|
+
|
|
5
|
+
export type FooterZones = {
|
|
6
|
+
left: string;
|
|
7
|
+
middle: string;
|
|
8
|
+
right: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function fullFooterFitsAligned(
|
|
12
|
+
zones: FooterZones,
|
|
13
|
+
innerWidth: number,
|
|
14
|
+
): boolean {
|
|
15
|
+
const leftWidth = visibleWidth(zones.left);
|
|
16
|
+
const middleWidth = visibleWidth(zones.middle);
|
|
17
|
+
const rightWidth = visibleWidth(zones.right);
|
|
18
|
+
if (middleWidth === 0) {
|
|
19
|
+
return (
|
|
20
|
+
leftWidth + rightWidth + (leftWidth > 0 && rightWidth > 0 ? 1 : 0) <=
|
|
21
|
+
innerWidth
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const gapWidth = innerWidth - leftWidth - rightWidth;
|
|
26
|
+
if (gapWidth < middleWidth) return false;
|
|
27
|
+
const leftPadding = Math.floor((gapWidth - middleWidth) / 2);
|
|
28
|
+
const rightPadding = gapWidth - middleWidth - leftPadding;
|
|
29
|
+
return (
|
|
30
|
+
(leftWidth === 0 || leftPadding >= 1) &&
|
|
31
|
+
(rightWidth === 0 || rightPadding >= 1)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function joinZones(parts: string[]): string {
|
|
36
|
+
return parts.filter(Boolean).join(" ");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validRows(rows: string[], innerWidth: number): string[] | undefined {
|
|
40
|
+
const nonEmpty = rows.filter(Boolean);
|
|
41
|
+
if (nonEmpty.length === 0 || nonEmpty.length > 2) return undefined;
|
|
42
|
+
return nonEmpty.every((row) => visibleWidth(row) <= innerWidth)
|
|
43
|
+
? nonEmpty
|
|
44
|
+
: undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Prefer project identity alone, with middle/right status on the second row. */
|
|
48
|
+
export function reflowFullFooter(
|
|
49
|
+
zones: FooterZones,
|
|
50
|
+
innerWidth: number,
|
|
51
|
+
): string[] | undefined {
|
|
52
|
+
const preferred = validRows(
|
|
53
|
+
[zones.left, joinZones([zones.middle, zones.right])],
|
|
54
|
+
innerWidth,
|
|
55
|
+
);
|
|
56
|
+
if (preferred) return preferred;
|
|
57
|
+
return validRows(
|
|
58
|
+
[joinZones([zones.left, zones.middle]), zones.right],
|
|
59
|
+
innerWidth,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function compactChunkBudget(innerWidth: number): number {
|
|
64
|
+
return Math.max(8, Math.floor((innerWidth - 1) / 2));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type CompactLayoutChunk = {
|
|
68
|
+
text: string;
|
|
69
|
+
boundary: CompactBoundaryKind;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type PackedRow = {
|
|
73
|
+
text: string;
|
|
74
|
+
endsWithRendererEllipsis: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function fitChunk(chunk: string, innerWidth: number): PackedRow {
|
|
78
|
+
if (visibleWidth(chunk) <= innerWidth) {
|
|
79
|
+
return { text: chunk, endsWithRendererEllipsis: false };
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
text: truncateToWidth(chunk, innerWidth, "…"),
|
|
83
|
+
endsWithRendererEllipsis: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function appendOmissionMarker(row: PackedRow, innerWidth: number): PackedRow {
|
|
88
|
+
if (row.endsWithRendererEllipsis) return row;
|
|
89
|
+
if (innerWidth <= 1) return { text: "…", endsWithRendererEllipsis: true };
|
|
90
|
+
return {
|
|
91
|
+
text: `${truncateToWidth(row.text, innerWidth - 1, "")}…`,
|
|
92
|
+
endsWithRendererEllipsis: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function packCompactChunks(
|
|
97
|
+
chunks: CompactLayoutChunk[],
|
|
98
|
+
innerWidth: number,
|
|
99
|
+
maxLines: CompactFooterMaxLines,
|
|
100
|
+
separator: string,
|
|
101
|
+
): string[] {
|
|
102
|
+
if (innerWidth <= 0) return [""];
|
|
103
|
+
const content = chunks
|
|
104
|
+
.map((chunk) => ({ ...chunk, text: chunk.text.trim() }))
|
|
105
|
+
.filter((chunk) => chunk.text.length > 0);
|
|
106
|
+
if (content.length === 0) return [""];
|
|
107
|
+
|
|
108
|
+
const finiteLimit =
|
|
109
|
+
maxLines === "unlimited" ? Number.POSITIVE_INFINITY : maxLines;
|
|
110
|
+
const rows: PackedRow[] = [];
|
|
111
|
+
let current: PackedRow | undefined;
|
|
112
|
+
let omitted = false;
|
|
113
|
+
|
|
114
|
+
for (const chunk of content) {
|
|
115
|
+
const fitted = fitChunk(chunk.text, innerWidth);
|
|
116
|
+
if (!current) {
|
|
117
|
+
current = fitted;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const join = chunk.boundary === "separator" ? separator : " ";
|
|
122
|
+
const candidate = `${current.text}${join}${fitted.text}`;
|
|
123
|
+
if (visibleWidth(candidate) <= innerWidth) {
|
|
124
|
+
current = {
|
|
125
|
+
text: candidate,
|
|
126
|
+
endsWithRendererEllipsis: fitted.endsWithRendererEllipsis,
|
|
127
|
+
};
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (rows.length + 1 < finiteLimit) {
|
|
132
|
+
rows.push(current);
|
|
133
|
+
current = fitted;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
omitted = true;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (current)
|
|
142
|
+
rows.push(omitted ? appendOmissionMarker(current, innerWidth) : current);
|
|
143
|
+
return rows.map((row) => truncateToWidth(row.text, innerWidth, ""));
|
|
144
|
+
}
|