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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/extensions/app/config/renderer.ts +452 -0
  4. package/extensions/app/config/shell.ts +2484 -0
  5. package/extensions/app/ownership.ts +18 -0
  6. package/extensions/app/panel.ts +533 -0
  7. package/extensions/app/presets.ts +36 -0
  8. package/extensions/features/agent-summary/core.ts +194 -0
  9. package/extensions/features/agent-summary/index.ts +37 -0
  10. package/extensions/features/compact-thinking.ts +1262 -0
  11. package/extensions/features/context.ts +857 -0
  12. package/extensions/features/reference/index.ts +643 -0
  13. package/extensions/features/reference/session.ts +278 -0
  14. package/extensions/features/reference/subagent.ts +218 -0
  15. package/extensions/features/shell/aliases.ts +28 -0
  16. package/extensions/features/shell/flush-docked-bash.ts +39 -0
  17. package/extensions/features/shell/startup-header.ts +357 -0
  18. package/extensions/features/shell/working-message.ts +226 -0
  19. package/extensions/index.ts +52 -0
  20. package/extensions/shell/accent-rail-editor.ts +112 -0
  21. package/extensions/shell/accent-rail-layout-patch.ts +530 -0
  22. package/extensions/shell/completion-menu.ts +186 -0
  23. package/extensions/shell/editor-metadata-format.ts +287 -0
  24. package/extensions/shell/editor-transfer.ts +73 -0
  25. package/extensions/shell/extension-status.ts +102 -0
  26. package/extensions/shell/footer-format.ts +388 -0
  27. package/extensions/shell/footer-layout.ts +144 -0
  28. package/extensions/shell/footer.ts +1035 -0
  29. package/extensions/shell/format.ts +577 -0
  30. package/extensions/shell/git.ts +434 -0
  31. package/extensions/shell/icons.ts +261 -0
  32. package/extensions/shell/index.ts +1659 -0
  33. package/extensions/shell/interaction-summary.ts +1194 -0
  34. package/extensions/shell/live-context.ts +82 -0
  35. package/extensions/shell/minimalist-editor.ts +548 -0
  36. package/extensions/shell/package-version.ts +675 -0
  37. package/extensions/shell/project-refresh.ts +130 -0
  38. package/extensions/shell/project-state.ts +60 -0
  39. package/extensions/shell/prototype-patch-registry.ts +183 -0
  40. package/extensions/shell/runtime.ts +915 -0
  41. package/extensions/shell/selector-border.ts +109 -0
  42. package/extensions/shell/session-lifecycle.ts +60 -0
  43. package/extensions/shell/settings-command.ts +2144 -0
  44. package/extensions/shell/settings-previews.ts +137 -0
  45. package/extensions/shell/state.ts +83 -0
  46. package/extensions/shell/style.ts +480 -0
  47. package/extensions/shell/telemetry.ts +90 -0
  48. package/extensions/shell/ui.ts +1326 -0
  49. package/extensions/shell/user-message-osc.ts +279 -0
  50. package/extensions/shell/user-message-styles.ts +266 -0
  51. package/extensions/shell/user-message.ts +226 -0
  52. package/extensions/shell/working-line-messages.ts +24 -0
  53. package/extensions/shell/working-line-spinners.ts +54 -0
  54. package/extensions/shell/working-line.ts +1603 -0
  55. package/extensions/tools/ansi-color.ts +44 -0
  56. package/extensions/tools/ansi-text.ts +52 -0
  57. package/extensions/tools/component-tree.ts +37 -0
  58. package/extensions/tools/format.ts +28 -0
  59. package/extensions/tools/fullscreen-detect.ts +11 -0
  60. package/extensions/tools/patch-keys.ts +158 -0
  61. package/extensions/tools/sgr-mouse.ts +59 -0
  62. package/extensions/tools/tool-loading-icon.ts +20 -0
  63. package/extensions/tools/tool-result-sanitize.ts +27 -0
  64. package/extensions/transcript/index.ts +65 -0
  65. package/extensions/transcript/renderer/compact-mode.ts +1443 -0
  66. package/extensions/transcript/renderer/default-mode.ts +735 -0
  67. package/extensions/transcript/renderer/index.ts +286 -0
  68. package/extensions/transcript/renderer/markdown-enhance.ts +325 -0
  69. package/extensions/transcript/renderer/mouse/hover.ts +175 -0
  70. package/extensions/transcript/renderer/mouse/interaction.ts +1016 -0
  71. package/extensions/transcript/renderer/mouse/layout.ts +207 -0
  72. package/extensions/transcript/renderer/mouse/packets.ts +96 -0
  73. package/extensions/transcript/renderer/mouse/scroll.ts +211 -0
  74. package/extensions/transcript/renderer/tool/diff/ATTRIBUTION.md +28 -0
  75. package/extensions/transcript/renderer/tool/diff/ansi-utils.ts +128 -0
  76. package/extensions/transcript/renderer/tool/diff/diff-component.ts +112 -0
  77. package/extensions/transcript/renderer/tool/diff/diff-edit-render.ts +255 -0
  78. package/extensions/transcript/renderer/tool/diff/diff-header.ts +184 -0
  79. package/extensions/transcript/renderer/tool/diff/diff-highlight.ts +131 -0
  80. package/extensions/transcript/renderer/tool/diff/diff-inline.ts +350 -0
  81. package/extensions/transcript/renderer/tool/diff/diff-layout.ts +870 -0
  82. package/extensions/transcript/renderer/tool/diff/diff-limits.ts +189 -0
  83. package/extensions/transcript/renderer/tool/diff/diff-palette.ts +436 -0
  84. package/extensions/transcript/renderer/tool/diff/diff-parse.ts +536 -0
  85. package/extensions/transcript/renderer/tool/diff/diff-presentation.ts +79 -0
  86. package/extensions/transcript/renderer/tool/diff/diff-renderer.ts +29 -0
  87. package/extensions/transcript/renderer/tool/diff/diff-text.ts +57 -0
  88. package/extensions/transcript/renderer/tool/diff/diff-write-render.ts +629 -0
  89. package/extensions/transcript/renderer/tool/diff/index.ts +147 -0
  90. package/extensions/transcript/renderer/tool/diff/line-width-safety.ts +100 -0
  91. package/extensions/transcript/renderer/tool/diff/shiki-highlight.ts +104 -0
  92. package/extensions/transcript/renderer/tool/diff/write-execution.ts +124 -0
  93. package/extensions/transcript/renderer/tool/grouping.ts +619 -0
  94. package/extensions/transcript/renderer/tool/message-display.ts +183 -0
  95. package/extensions/transcript/renderer/tool/names.ts +195 -0
  96. package/extensions/transcript/renderer/tool/result.ts +842 -0
  97. package/extensions/transcript/renderer/tool/show-more-hint.ts +42 -0
  98. package/extensions/transcript/renderer/transcript-refresh.ts +79 -0
  99. package/package.json +58 -0
  100. package/themes/cc-dark.json +91 -0
  101. 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
+ }