pi-zentui 0.13.0 → 0.14.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/README.md CHANGED
@@ -157,6 +157,9 @@ Default config values — copy this and change any value you want:
157
157
  {
158
158
  "projectRefreshIntervalMs": 30000,
159
159
  "footerFormat": "",
160
+ "responsiveFooter": true,
161
+ "compactFooterFormat": "$cwd$wrap(in $session_name)$wrap(on $git_branch) $git_status$wrap$context$wrap_sep$tokens",
162
+ "compactFooterMaxLines": 2,
160
163
  "editorMetadataFormat": "$model $provider( $thinking)",
161
164
  "separator": "pipe",
162
165
  "contextStyle": "text",
@@ -290,7 +293,10 @@ Default config values — copy this and change any value you want:
290
293
  - `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
291
294
  - `features`: `editor` enables Zentui's custom editor, selector borders, and previous-message chrome. `statusLine` enables Zentui's custom footer/status line. `copyFriendly` hides editor and previous-message rail glyphs so native terminal selection copies less chrome. All three can be changed from `/zentui` or direct slash-command arguments.
292
295
  - `footerSegments`: show or hide individual built-in footer segments (`cwd`, `sessionName`, `gitBranch`, `gitStatus`, `gitCounts`, `gitCommit`, `gitMetrics`, `runtime`, `packageVersion`, `sessionDuration`, `username`, `time`, `os`, `context`, `tokens`, `cost`). Toggle them from the `Built-in segments` tab in `/zentui`.
293
- - `footerFormat`: optional Starship-style template string that fully controls the footer layout. When set, it overrides `footerSegments`. See [Footer Format Template](#footer-format-template) below. The `/zentui` **Layout** tab configures context style, separator, path display mode/depth, branch length, and icon mode; set or clear custom formats with `/zentui format`.
296
+ - `footerFormat`: optional Starship-style template string that fully controls the footer layout. When set, it overrides `footerSegments`. See [Footer Format Template](#footer-format-template) below. The `/zentui` **Layout** tab configures responsive behavior, compact rows, context style, separator, path display mode/depth, branch length, and icon mode; set or clear custom formats with `/zentui format`.
297
+ - `responsiveFooter`: enabled by default. Zentui keeps the current aligned one-row footer while every settings-resolved left/middle/right zone fits without layout truncation. Otherwise it tries two complete left-aligned rows, preferring `left` / `middle right` and then `left middle` / `right`. Only when neither split fits does it use `compactFooterFormat`. Set `false` to restore the legacy one-row fitting behavior. Selection uses measured terminal-cell width, not fixed device breakpoints.
298
+ - `compactFooterFormat`: JSON-only template used by the compact stage. The default keeps cwd, session name, git branch/status, context, and abbreviated token/cache metrics. A top-level `$wrap` is an automatic wrap opportunity: one space on the same row or no space at a row break. `$wrap_sep` is the same kind of boundary but renders the styled ` | ` divider only when its adjacent chunks share a row. Nested uses of either boundary remain empty variables. `$fill` is ignored. A standalone `$extensions` chunk inserts active non-`off` extension statuses in left/middle/right placement order; embedded uses render empty. Custom `footerFormat` values use this built-in compact fallback unless this key is also customized.
299
+ - `compactFooterMaxLines`: `1`, `2`, `3`, or `"unlimited"` (default `2`). Finite limits crop remaining chunks with exactly one trailing `…`. Compact cwd always uses basename mode; cwd/session/branch chunks target half the available row width before final ANSI-aware clamping. `/zentui` exposes the responsive toggle and row limit, while compact format editing remains JSON-only. Pi supplies footer width but no supported viewport-height budget, so `"unlimited"` is explicit.
294
300
  - `gitCommit`: Starship [`git_commit`](https://starship.rs/config/#git-commit)-style options for the `gitCommit` footer segment. `hashLength` (default `7`, clamped to `4`–`40`) controls the short-hash display length. `onlyDetached` (default `true`) shows the hash mainly on detached HEAD. `showTag` (default `true`) appends an exact-match tag (`git describe --tags --exact-match HEAD`). The tag probe piggybacks on the existing git refresh — it only runs when both the segment and `showTag` are on, and misses/failures degrade silently.
295
301
  - `gitMetrics`: Starship [`git_metrics`](https://starship.rs/config/#git-metrics)-style options for the `gitMetrics` footer segment. Uses `git diff HEAD --numstat` (staged + unstaged combined — the Starship “total dirty” view) to show aggregate `+added −deleted` line counts. `onlyNonzero` (default `true`) omits each zero component independently and hides the segment entirely at `0/0`. `ignoreSubmodules` (default `false`) adds `--ignore-submodules=all`. The numstat diff piggybacks on the existing git refresh and uses a hard 2s timeout; a metrics-only failure degrades silently without discarding fresh branch/status data. On very large monorepos the diff may lag or be omitted on timeout.
296
302
  - `extensionStatuses`: controls third-party statuses published by other Pi extensions through `ctx.ui.setStatus()`. `defaultPlacement` and each `placements` value can be `off`, `left`, `middle`, or `right`. The `Extension segments` tab in `/zentui` lists only statuses that are currently active.
@@ -33,6 +33,10 @@ export type { IconMode } from "./icons";
33
33
  export type ContextStyle = "text" | "gauge" | "text+gauge";
34
34
  export type SeparatorStyle = "pipe" | "dot" | "chevron" | "none";
35
35
  export type ModelLabelSource = "id" | "name";
36
+ export type CompactFooterMaxLines = 1 | 2 | 3 | "unlimited";
37
+
38
+ export const DEFAULT_COMPACT_FOOTER_FORMAT =
39
+ "$cwd$wrap(in $session_name)$wrap(on $git_branch) $git_status$wrap$context$wrap_sep$tokens";
36
40
 
37
41
  export type ContextThresholds = {
38
42
  warning: number;
@@ -127,6 +131,9 @@ export const DEFAULT_EDITOR_METADATA_FORMAT = "$model $provider( $thinking)";
127
131
  export type PolishedTuiConfig = {
128
132
  projectRefreshIntervalMs: number;
129
133
  footerFormat: string;
134
+ responsiveFooter: boolean;
135
+ compactFooterFormat: string;
136
+ compactFooterMaxLines: CompactFooterMaxLines;
130
137
  editorMetadataFormat: string;
131
138
  separator: SeparatorStyle;
132
139
  contextStyle: ContextStyle;
@@ -225,6 +232,9 @@ export const configPath = join(getAgentDir(), "zentui.json");
225
232
  export const defaultConfig: PolishedTuiConfig = {
226
233
  projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
227
234
  footerFormat: "",
235
+ responsiveFooter: true,
236
+ compactFooterFormat: DEFAULT_COMPACT_FOOTER_FORMAT,
237
+ compactFooterMaxLines: 2,
228
238
  editorMetadataFormat: DEFAULT_EDITOR_METADATA_FORMAT,
229
239
  separator: "pipe",
230
240
  contextStyle: "text",
@@ -399,6 +409,10 @@ function stringValue(record: Record<string, unknown>, key: string): string | und
399
409
  return typeof value === "string" ? value : undefined;
400
410
  }
401
411
 
412
+ function parseCompactFooterMaxLines(value: unknown): CompactFooterMaxLines {
413
+ return value === 1 || value === 2 || value === 3 || value === "unlimited" ? value : 2;
414
+ }
415
+
402
416
  function colorValue(record: Record<string, unknown>, key: string): string | undefined {
403
417
  const value = stringValue(record, key);
404
418
  return value !== undefined && isSupportedColorSpec(value) ? value : undefined;
@@ -773,9 +787,19 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
773
787
  ? normalizeFixedEditorConfig(config.fixedEditor as Record<string, unknown>)
774
788
  : defaultConfig.fixedEditor;
775
789
  const editorMetadataFormat = stringValue(config, "editorMetadataFormat");
790
+ const compactFooterFormat = stringValue(config, "compactFooterFormat");
776
791
  return {
777
792
  projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
778
793
  footerFormat: stringValue(config, "footerFormat") ?? "",
794
+ responsiveFooter:
795
+ typeof config.responsiveFooter === "boolean"
796
+ ? config.responsiveFooter
797
+ : defaultConfig.responsiveFooter,
798
+ compactFooterFormat:
799
+ compactFooterFormat && compactFooterFormat.length > 0
800
+ ? compactFooterFormat
801
+ : DEFAULT_COMPACT_FOOTER_FORMAT,
802
+ compactFooterMaxLines: parseCompactFooterMaxLines(config.compactFooterMaxLines),
779
803
  editorMetadataFormat:
780
804
  editorMetadataFormat && editorMetadataFormat.length > 0
781
805
  ? editorMetadataFormat
@@ -879,6 +903,25 @@ export function saveFooterFormatPatch(value: string, path = configPath): Polishe
879
903
  });
880
904
  }
881
905
 
906
+ export function saveResponsiveFooterPatch(
907
+ patch: Partial<
908
+ Pick<PolishedTuiConfig, "responsiveFooter" | "compactFooterFormat" | "compactFooterMaxLines">
909
+ >,
910
+ path = configPath,
911
+ ): PolishedTuiConfig {
912
+ return mutateConfig(path, (record) => {
913
+ if (typeof patch.responsiveFooter === "boolean") {
914
+ record.responsiveFooter = patch.responsiveFooter;
915
+ }
916
+ if (typeof patch.compactFooterFormat === "string") {
917
+ record.compactFooterFormat = patch.compactFooterFormat;
918
+ }
919
+ if (patch.compactFooterMaxLines !== undefined) {
920
+ record.compactFooterMaxLines = parseCompactFooterMaxLines(patch.compactFooterMaxLines);
921
+ }
922
+ });
923
+ }
924
+
882
925
  export function saveIconsModePatch(mode: IconMode, path = configPath): PolishedTuiConfig {
883
926
  return mutateConfig(path, (record) => {
884
927
  const existing = isRecord(record.icons) ? { ...(record.icons as Record<string, unknown>) } : {};
@@ -13,6 +13,12 @@ export type FormatToken =
13
13
  | { kind: "fill" }
14
14
  | { kind: "group"; tokens: FormatToken[] };
15
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
+
16
22
  const TOKEN_REGEX = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
17
23
 
18
24
  /**
@@ -151,6 +157,85 @@ export function renderFormatSplit(
151
157
  };
152
158
  }
153
159
 
160
+ export function compileCompactFormat(tokens: FormatToken[]): CompactFormatChunk[] {
161
+ const chunks: CompactFormatChunk[] = [];
162
+ let current: FormatToken[] = [];
163
+ let incomingBoundary: CompactBoundaryKind = "space";
164
+
165
+ const flush = () => {
166
+ const normalized = trimBoundaryWhitespace(current);
167
+ current = [];
168
+ if (normalized.length === 0) return;
169
+ if (
170
+ normalized.length === 1 &&
171
+ normalized[0]?.kind === "var" &&
172
+ normalized[0].name === "extensions"
173
+ ) {
174
+ chunks.push({ kind: "extensions", boundary: incomingBoundary });
175
+ return;
176
+ }
177
+ chunks.push({ kind: "tokens", tokens: normalized, boundary: incomingBoundary });
178
+ };
179
+
180
+ for (const token of tokens) {
181
+ if (token.kind === "var" && (token.name === "wrap" || token.name === "wrap_sep")) {
182
+ flush();
183
+ incomingBoundary = token.name === "wrap_sep" ? "separator" : "space";
184
+ continue;
185
+ }
186
+ if (token.kind === "fill") continue;
187
+ current.push(token);
188
+ }
189
+ flush();
190
+ return chunks;
191
+ }
192
+
193
+ function trimBoundaryWhitespace(tokens: FormatToken[]): FormatToken[] {
194
+ const result = tokens.map((token) => (token.kind === "text" ? { ...token } : token));
195
+ while (result[0]?.kind === "text") {
196
+ result[0].value = result[0].value.replace(/^\s+/, "");
197
+ if (result[0].value) break;
198
+ result.shift();
199
+ }
200
+ while (result.at(-1)?.kind === "text") {
201
+ const last = result.at(-1);
202
+ if (last?.kind !== "text") break;
203
+ last.value = last.value.replace(/\s+$/, "");
204
+ if (last.value) break;
205
+ result.pop();
206
+ }
207
+ return result;
208
+ }
209
+
210
+ export function renderFormatTokens(
211
+ tokens: FormatToken[],
212
+ renderVariable: (name: string) => string,
213
+ ): string {
214
+ return renderTokenSlice(tokens, 0, tokens.length, renderVariable);
215
+ }
216
+
217
+ export function collectFooterFormatReferences(
218
+ tokens: FormatToken[],
219
+ aliases: Record<string, string> = {},
220
+ ): Set<string> {
221
+ const references = new Set<string>();
222
+ const visit = (items: FormatToken[]) => {
223
+ for (const token of items) {
224
+ if (token.kind === "group") {
225
+ visit(token.tokens);
226
+ continue;
227
+ }
228
+ if (token.kind !== "var") continue;
229
+ const canonical = aliases[token.name] ?? token.name;
230
+ if (canonical !== "wrap" && canonical !== "wrap_sep" && canonical !== "extensions") {
231
+ references.add(canonical);
232
+ }
233
+ }
234
+ };
235
+ visit(tokens);
236
+ return references;
237
+ }
238
+
154
239
  function findTopLevelFillIndices(tokens: FormatToken[]): number[] {
155
240
  const fillIndices: number[] = [];
156
241
  for (let index = 0; index < tokens.length; index++) {
@@ -0,0 +1,122 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { CompactFooterMaxLines } from "./config";
3
+ import type { CompactBoundaryKind } from "./footer-format";
4
+
5
+ export type FooterZones = {
6
+ left: string;
7
+ middle: string;
8
+ right: string;
9
+ };
10
+
11
+ export function fullFooterFitsAligned(zones: FooterZones, innerWidth: number): boolean {
12
+ const leftWidth = visibleWidth(zones.left);
13
+ const middleWidth = visibleWidth(zones.middle);
14
+ const rightWidth = visibleWidth(zones.right);
15
+ if (middleWidth === 0) {
16
+ return leftWidth + rightWidth + (leftWidth > 0 && rightWidth > 0 ? 1 : 0) <= innerWidth;
17
+ }
18
+
19
+ const gapWidth = innerWidth - leftWidth - rightWidth;
20
+ if (gapWidth < middleWidth) return false;
21
+ const leftPadding = Math.floor((gapWidth - middleWidth) / 2);
22
+ const rightPadding = gapWidth - middleWidth - leftPadding;
23
+ return (leftWidth === 0 || leftPadding >= 1) && (rightWidth === 0 || rightPadding >= 1);
24
+ }
25
+
26
+ function joinZones(parts: string[]): string {
27
+ return parts.filter(Boolean).join(" ");
28
+ }
29
+
30
+ function validRows(rows: string[], innerWidth: number): string[] | undefined {
31
+ const nonEmpty = rows.filter(Boolean);
32
+ if (nonEmpty.length === 0 || nonEmpty.length > 2) return undefined;
33
+ return nonEmpty.every((row) => visibleWidth(row) <= innerWidth) ? nonEmpty : undefined;
34
+ }
35
+
36
+ /** Prefer project identity alone, with middle/right status on the second row. */
37
+ export function reflowFullFooter(zones: FooterZones, innerWidth: number): string[] | undefined {
38
+ const preferred = validRows([zones.left, joinZones([zones.middle, zones.right])], innerWidth);
39
+ if (preferred) return preferred;
40
+ return validRows([joinZones([zones.left, zones.middle]), zones.right], innerWidth);
41
+ }
42
+
43
+ export function compactChunkBudget(innerWidth: number): number {
44
+ return Math.max(8, Math.floor((innerWidth - 1) / 2));
45
+ }
46
+
47
+ export type CompactLayoutChunk = {
48
+ text: string;
49
+ boundary: CompactBoundaryKind;
50
+ };
51
+
52
+ type PackedRow = {
53
+ text: string;
54
+ endsWithRendererEllipsis: boolean;
55
+ };
56
+
57
+ function fitChunk(chunk: string, innerWidth: number): PackedRow {
58
+ if (visibleWidth(chunk) <= innerWidth) {
59
+ return { text: chunk, endsWithRendererEllipsis: false };
60
+ }
61
+ return {
62
+ text: truncateToWidth(chunk, innerWidth, "…"),
63
+ endsWithRendererEllipsis: true,
64
+ };
65
+ }
66
+
67
+ function appendOmissionMarker(row: PackedRow, innerWidth: number): PackedRow {
68
+ if (row.endsWithRendererEllipsis) return row;
69
+ if (innerWidth <= 1) return { text: "…", endsWithRendererEllipsis: true };
70
+ return {
71
+ text: `${truncateToWidth(row.text, innerWidth - 1, "")}…`,
72
+ endsWithRendererEllipsis: true,
73
+ };
74
+ }
75
+
76
+ export function packCompactChunks(
77
+ chunks: CompactLayoutChunk[],
78
+ innerWidth: number,
79
+ maxLines: CompactFooterMaxLines,
80
+ separator: string,
81
+ ): string[] {
82
+ if (innerWidth <= 0) return [""];
83
+ const content = chunks
84
+ .map((chunk) => ({ ...chunk, text: chunk.text.trim() }))
85
+ .filter((chunk) => chunk.text.length > 0);
86
+ if (content.length === 0) return [""];
87
+
88
+ const finiteLimit = maxLines === "unlimited" ? Number.POSITIVE_INFINITY : maxLines;
89
+ const rows: PackedRow[] = [];
90
+ let current: PackedRow | undefined;
91
+ let omitted = false;
92
+
93
+ for (const chunk of content) {
94
+ const fitted = fitChunk(chunk.text, innerWidth);
95
+ if (!current) {
96
+ current = fitted;
97
+ continue;
98
+ }
99
+
100
+ const join = chunk.boundary === "separator" ? separator : " ";
101
+ const candidate = `${current.text}${join}${fitted.text}`;
102
+ if (visibleWidth(candidate) <= innerWidth) {
103
+ current = {
104
+ text: candidate,
105
+ endsWithRendererEllipsis: fitted.endsWithRendererEllipsis,
106
+ };
107
+ continue;
108
+ }
109
+
110
+ if (rows.length + 1 < finiteLimit) {
111
+ rows.push(current);
112
+ current = fitted;
113
+ continue;
114
+ }
115
+
116
+ omitted = true;
117
+ break;
118
+ }
119
+
120
+ if (current) rows.push(omitted ? appendOmissionMarker(current, innerWidth) : current);
121
+ return rows.map((row) => truncateToWidth(row.text, innerWidth, ""));
122
+ }
@@ -7,7 +7,20 @@ import {
7
7
  type ExtensionStatusSegment,
8
8
  sanitizeExtensionStatusText,
9
9
  } from "./extension-status";
10
- import { parseFooterFormat, renderFormatSplit, stripOrphanSeparators } from "./footer-format";
10
+ import {
11
+ collectFooterFormatReferences,
12
+ compileCompactFormat,
13
+ parseFooterFormat,
14
+ renderFormatSplit,
15
+ renderFormatTokens,
16
+ stripOrphanSeparators,
17
+ } from "./footer-format";
18
+ import {
19
+ compactChunkBudget,
20
+ fullFooterFitsAligned,
21
+ packCompactChunks,
22
+ reflowFullFooter,
23
+ } from "./footer-layout";
11
24
  import {
12
25
  buildContextDisplayLabel,
13
26
  buildSessionDurationLabel,
@@ -181,6 +194,18 @@ export function installFooter(
181
194
  render(width: number): string[] {
182
195
  if (width <= 0) return [""];
183
196
  const config = getConfig();
197
+ const wideFormatTokens = config.footerFormat ? parseFooterFormat(config.footerFormat) : [];
198
+ const compactFormatTokens = config.responsiveFooter
199
+ ? parseFooterFormat(config.compactFooterFormat)
200
+ : [];
201
+ const wideReferences = collectFooterFormatReferences(
202
+ wideFormatTokens,
203
+ FOOTER_FORMAT_ALIASES,
204
+ );
205
+ const compactReferences = collectFooterFormatReferences(
206
+ compactFormatTokens,
207
+ FOOTER_FORMAT_ALIASES,
208
+ );
184
209
  const colorSource = config.colorSources.starship;
185
210
  const iconMode = config.icons.mode;
186
211
  const separator = renderStyleForSource(
@@ -199,9 +224,10 @@ export function installFooter(
199
224
  depth: config.pathDisplay.depth,
200
225
  }),
201
226
  );
202
- const needsSessionName = config.footerFormat
203
- ? /(?:\$session_name\b|\$\{session_name\})/.test(config.footerFormat)
204
- : config.footerSegments.sessionName;
227
+ const needsSessionName =
228
+ (config.footerFormat
229
+ ? wideReferences.has("session_name")
230
+ : config.footerSegments.sessionName) || compactReferences.has("session_name");
205
231
  const sessionName = needsSessionName
206
232
  ? sanitizeExtensionStatusText(ctx.sessionManager.getSessionName() ?? "")
207
233
  : "";
@@ -549,7 +575,7 @@ export function installFooter(
549
575
  left: fmtLeft,
550
576
  middle: fmtMiddle,
551
577
  right: fmtRight,
552
- } = renderFormatSplit(parseFooterFormat(config.footerFormat), renderVariable);
578
+ } = renderFormatSplit(wideFormatTokens, renderVariable);
553
579
  contentLeft = stripOrphanSeparators(fmtLeft);
554
580
  contentMiddle = stripOrphanSeparators(fmtMiddle);
555
581
  contentRight = stripOrphanSeparators(fmtRight);
@@ -563,21 +589,128 @@ export function installFooter(
563
589
  segment.colorMode === "original"
564
590
  ? segment.text
565
591
  : renderStyleForSource(theme, colorSource, config.colors.extensionStatus, segment.text);
592
+ const extensionLeftSegments = extensionStatuses.left.map(renderExtensionStatus);
566
593
  const extensionMiddleSegments = extensionStatuses.middle.map(renderExtensionStatus);
594
+ const extensionRightSegments = extensionStatuses.right.map(renderExtensionStatus);
567
595
  const middleSegments = contentMiddle
568
596
  ? [contentMiddle, ...extensionMiddleSegments]
569
597
  : extensionMiddleSegments;
570
- const content = composeFooterContent(
571
- contentLeft,
572
- contentRight,
573
- extensionStatuses.left.map(renderExtensionStatus),
574
- middleSegments,
575
- extensionStatuses.right.map(renderExtensionStatus),
576
- separator,
577
- innerWidth,
598
+ const renderLegacyContent = () =>
599
+ composeFooterContent(
600
+ contentLeft,
601
+ contentRight,
602
+ extensionLeftSegments,
603
+ middleSegments,
604
+ extensionRightSegments,
605
+ separator,
606
+ innerWidth,
607
+ );
608
+ const frameRows = (rows: string[]) =>
609
+ rows.map((row) => {
610
+ const framed = width > 2 ? ` ${truncateToWidth(row, width - 2, "")} ` : row;
611
+ return truncateToWidth(framed, width, "");
612
+ });
613
+
614
+ if (!config.responsiveFooter) return frameRows([renderLegacyContent()]);
615
+
616
+ const fullZones = {
617
+ left: appendStatusArea(
618
+ contentLeft,
619
+ joinStatusTexts(extensionLeftSegments, separator),
620
+ separator,
621
+ ),
622
+ middle: appendStatusArea(
623
+ contentMiddle,
624
+ joinStatusTexts(extensionMiddleSegments, separator),
625
+ separator,
626
+ ),
627
+ right: prependStatusArea(
628
+ contentRight,
629
+ joinStatusTexts(extensionRightSegments, separator),
630
+ separator,
631
+ ),
632
+ };
633
+ if (fullFooterFitsAligned(fullZones, innerWidth)) {
634
+ return frameRows([renderLegacyContent()]);
635
+ }
636
+
637
+ const reflowed = reflowFullFooter(fullZones, innerWidth);
638
+ if (reflowed) return frameRows(reflowed);
639
+
640
+ const chunkBudget = compactChunkBudget(innerWidth);
641
+ const compactCwdLabel = truncateToWidth(
642
+ renderStyleForSource(
643
+ theme,
644
+ colorSource,
645
+ config.colors.cwd,
646
+ formatCwdLabel(ctx.cwd, config.icons.cwd, { mode: "basename", depth: 0 }),
647
+ ),
648
+ chunkBudget,
649
+ "…",
650
+ );
651
+ const compactSessionNameLabel = truncateToWidth(
652
+ sessionNameLabel,
653
+ Math.max(1, chunkBudget - visibleWidth("in ")),
654
+ "…",
655
+ );
656
+ const compactBranchBudget = Math.max(
657
+ 1,
658
+ chunkBudget - visibleWidth("on ") - (statusBlock ? visibleWidth(statusBlock) + 1 : 0),
659
+ );
660
+ const compactBranchLabel = truncateToWidth(
661
+ renderVariable("git_branch"),
662
+ compactBranchBudget,
663
+ "…",
664
+ );
665
+ const renderCompactVariable = (name: string): string => {
666
+ const canonical = FOOTER_FORMAT_ALIASES[name] ?? name;
667
+ switch (canonical) {
668
+ case "cwd":
669
+ return compactCwdLabel;
670
+ case "session_name":
671
+ return compactSessionNameLabel;
672
+ case "git_branch":
673
+ return compactBranchLabel;
674
+ default:
675
+ return renderVariable(name);
676
+ }
677
+ };
678
+ const compactChunks: Array<{
679
+ text: string;
680
+ boundary: "space" | "separator";
681
+ }> = [];
682
+ for (const chunk of compileCompactFormat(compactFormatTokens)) {
683
+ if (chunk.kind === "extensions") {
684
+ const statuses = [
685
+ ...extensionLeftSegments,
686
+ ...extensionMiddleSegments,
687
+ ...extensionRightSegments,
688
+ ];
689
+ for (const [index, text] of statuses.entries()) {
690
+ compactChunks.push({
691
+ text,
692
+ boundary: index === 0 ? chunk.boundary : "space",
693
+ });
694
+ }
695
+ continue;
696
+ }
697
+ let rendered = stripOrphanSeparators(
698
+ renderFormatTokens(chunk.tokens, renderCompactVariable),
699
+ );
700
+ const references = collectFooterFormatReferences(chunk.tokens, FOOTER_FORMAT_ALIASES);
701
+ if (["cwd", "session_name", "git_branch"].some((name) => references.has(name))) {
702
+ rendered = truncateToWidth(rendered, chunkBudget, "…");
703
+ }
704
+ if (rendered) compactChunks.push({ text: rendered, boundary: chunk.boundary });
705
+ }
706
+ return frameRows(
707
+ packCompactChunks(
708
+ compactChunks,
709
+ innerWidth,
710
+ config.compactFooterMaxLines,
711
+ renderVariable("sep"),
712
+ ),
578
713
  );
579
- const framed = width > 2 ? ` ${truncateToWidth(content, width - 2, "")} ` : content;
580
- return [truncateToWidth(framed, width, "")];
581
714
  },
582
715
  };
583
716
  });
@@ -12,6 +12,7 @@ import {
12
12
  type ExtensionStatusPlacement,
13
13
  ensureConfigExists,
14
14
  type FixedEditorConfig,
15
+ FOOTER_FORMAT_ALIASES,
15
16
  type FooterSegmentsConfig,
16
17
  type GitBranchConfig,
17
18
  type IconMode,
@@ -29,6 +30,7 @@ import {
29
30
  saveGitBranchPatch,
30
31
  saveIconsModePatch,
31
32
  savePathDisplayPatch,
33
+ saveResponsiveFooterPatch,
32
34
  saveSeparatorPatch,
33
35
  saveUiFeaturesPatch,
34
36
  type UiFeaturesConfig,
@@ -39,6 +41,7 @@ import {
39
41
  removeFixedEditorProbe,
40
42
  } from "./fixed-editor";
41
43
  import { installFooter } from "./footer";
44
+ import { collectFooterFormatReferences, parseFooterFormat } from "./footer-format";
42
45
  import { buildSessionDurationLabel, invalidateUsageTotalsCache } from "./format";
43
46
  import { emptyGitStatus, readGitStatus } from "./git";
44
47
  import { LiveContextController } from "./live-context";
@@ -82,6 +85,28 @@ function getZentuiEditorBaseFactory(factory: EditorFactory | undefined): EditorF
82
85
  return (factory as ZentuiEditorFactory | undefined)?.[ZENTUI_EDITOR_BASE_FACTORY];
83
86
  }
84
87
 
88
+ export function activeFooterReferences(config: PolishedTuiConfig): Set<string> {
89
+ const references = config.footerFormat
90
+ ? collectFooterFormatReferences(parseFooterFormat(config.footerFormat), FOOTER_FORMAT_ALIASES)
91
+ : new Set<string>([
92
+ ...(config.footerSegments.sessionName ? ["session_name"] : []),
93
+ ...(config.footerSegments.gitCommit ? ["git_commit"] : []),
94
+ ...(config.footerSegments.gitMetrics ? ["git_metrics"] : []),
95
+ ...(config.footerSegments.packageVersion ? ["package"] : []),
96
+ ...(config.footerSegments.sessionDuration ? ["session_duration"] : []),
97
+ ...(config.footerSegments.time ? ["time"] : []),
98
+ ]);
99
+ if (config.responsiveFooter) {
100
+ for (const name of collectFooterFormatReferences(
101
+ parseFooterFormat(config.compactFooterFormat),
102
+ FOOTER_FORMAT_ALIASES,
103
+ )) {
104
+ references.add(name);
105
+ }
106
+ }
107
+ return references;
108
+ }
109
+
85
110
  function isTuiContext(ctx: ExtensionContext): boolean {
86
111
  try {
87
112
  const mode = (ctx as ExtensionContext & { mode?: string }).mode;
@@ -108,6 +133,7 @@ export default function (pi: ExtensionAPI) {
108
133
  let wrappedEditorFactory: EditorFactory | undefined;
109
134
  let prototypePatchesInstalled = false;
110
135
  let stopSessionTimer: () => void = () => {};
136
+ let sessionTimerRequirements = "";
111
137
  let lastDurationLabel = "";
112
138
  let lastProjectCwd: string | undefined;
113
139
 
@@ -127,19 +153,12 @@ export default function (pi: ExtensionAPI) {
127
153
  if (!sessionLifecycle.isCurrent(generation)) return;
128
154
  const gitCommitConfig = currentConfig.gitCommit;
129
155
  const gitMetricsConfig = currentConfig.gitMetrics;
130
- const segments = currentConfig.footerSegments;
131
- const fmt = currentConfig.footerFormat;
132
- // Enable optional probes when the segment is on OR a custom footerFormat
133
- // references the relevant variable. Mirrors the session-duration timer
134
- // pattern so format-only users still get data.
135
- const formatNeedsTag = /\$\{?(?:git_tag|tag)\b/.test(fmt);
136
- const formatNeedsCommit = /\$\{?(?:git_commit|commit)\b/.test(fmt);
137
- const formatNeedsMetrics = /\$\{?(?:git_metrics|git_added|git_deleted)\b/.test(fmt);
138
- const formatNeedsPackage = /\$\{?(?:package|package_version)\b/.test(fmt);
156
+ const references = activeFooterReferences(currentConfig);
139
157
  const wantExactTag =
140
- ((segments.gitCommit || formatNeedsCommit) && gitCommitConfig.showTag) || formatNeedsTag;
141
- const wantMetrics = segments.gitMetrics || formatNeedsMetrics;
142
- const wantPackage = segments.packageVersion || formatNeedsPackage;
158
+ (references.has("git_commit") && gitCommitConfig.showTag) || references.has("git_tag");
159
+ const wantMetrics =
160
+ references.has("git_metrics") || references.has("git_added") || references.has("git_deleted");
161
+ const wantPackage = references.has("package") || references.has("package_version");
143
162
  const [git, runtime, packageVersion] = await Promise.all([
144
163
  readGitStatus(cwd, {
145
164
  readExactTag: wantExactTag,
@@ -183,23 +202,30 @@ export default function (pi: ExtensionAPI) {
183
202
  projectRefreshScheduler.stop();
184
203
  };
185
204
 
186
- const startSessionTimer = () => {
205
+ const reconcileSessionTimer = () => {
206
+ const references = activeFooterReferences(currentConfig);
207
+ const needsTime = references.has("time");
208
+ const needsDuration = references.has("session_duration");
209
+ const nextRequirements = needsTime || needsDuration ? `${needsTime}:${needsDuration}` : "";
210
+ if (
211
+ !sessionLifecycle.isCurrent() ||
212
+ !footerInstalled ||
213
+ !currentConfig.features.statusLine ||
214
+ !nextRequirements
215
+ ) {
216
+ stopSessionTimer();
217
+ sessionTimerRequirements = "";
218
+ lastDurationLabel = "";
219
+ return;
220
+ }
221
+ if (sessionTimerRequirements === nextRequirements) return;
222
+
187
223
  stopSessionTimer();
224
+ sessionTimerRequirements = nextRequirements;
188
225
  lastDurationLabel = "";
189
226
  const timer = setInterval(() => {
190
227
  if (!sessionLifecycle.isCurrent()) return;
191
- const segments = currentConfig.footerSegments;
192
- const formatNeedsTimer =
193
- currentConfig.footerFormat &&
194
- /\$\{?(?:time|session_duration|duration)\b/.test(currentConfig.footerFormat);
195
- if (
196
- !(
197
- currentConfig.features.statusLine &&
198
- (segments.sessionDuration || segments.time || formatNeedsTimer)
199
- )
200
- )
201
- return;
202
- if (segments.time || formatNeedsTimer) {
228
+ if (needsTime) {
203
229
  refresh();
204
230
  return;
205
231
  }
@@ -212,10 +238,27 @@ export default function (pi: ExtensionAPI) {
212
238
  }, 1000);
213
239
  stopSessionTimer = () => {
214
240
  clearInterval(timer);
241
+ sessionTimerRequirements = "";
215
242
  stopSessionTimer = () => {};
216
243
  };
217
244
  };
218
245
 
246
+ const sameReferences = (left: Set<string>, right: Set<string>) =>
247
+ left.size === right.size && [...left].every((name) => right.has(name));
248
+
249
+ const applyFooterDependencyConfigChange = (
250
+ ctx: ExtensionContext,
251
+ save: () => PolishedTuiConfig,
252
+ ) => {
253
+ const before = activeFooterReferences(currentConfig);
254
+ const nextConfig = save();
255
+ const after = activeFooterReferences(nextConfig);
256
+ currentConfig = nextConfig;
257
+ if (sameReferences(before, after)) return;
258
+ reconcileSessionTimer();
259
+ if (footerInstalled) scheduleProjectRefresh(ctx, { force: true });
260
+ };
261
+
219
262
  const installPrototypePatches = () => {
220
263
  if (prototypePatchesInstalled) return;
221
264
  const cleanupSelectorBorderStyle = installSelectorBorderStyle(getActiveTheme, getCurrentConfig);
@@ -347,7 +390,7 @@ export default function (pi: ExtensionAPI) {
347
390
  );
348
391
  scheduleProjectRefresh(ctx, { force: true });
349
392
  refresh();
350
- startSessionTimer();
393
+ reconcileSessionTimer();
351
394
  };
352
395
 
353
396
  const uninstallStatusLine = (ctx: ExtensionContext) => {
@@ -475,11 +518,17 @@ export default function (pi: ExtensionAPI) {
475
518
  : undefined,
476
519
  };
477
520
  },
478
- setFooterSegments(patch: Partial<FooterSegmentsConfig>) {
479
- currentConfig = saveFooterSegmentsPatch(patch);
521
+ setFooterSegments(patch: Partial<FooterSegmentsConfig>, ctx: ExtensionContext) {
522
+ applyFooterDependencyConfigChange(ctx, () => saveFooterSegmentsPatch(patch));
480
523
  },
481
- setFooterFormat(value: string) {
482
- currentConfig = saveFooterFormatPatch(value);
524
+ setFooterFormat(value: string, ctx: ExtensionContext) {
525
+ applyFooterDependencyConfigChange(ctx, () => saveFooterFormatPatch(value));
526
+ },
527
+ setResponsiveFooter(
528
+ patch: Partial<Pick<PolishedTuiConfig, "responsiveFooter" | "compactFooterMaxLines">>,
529
+ ctx: ExtensionContext,
530
+ ) {
531
+ applyFooterDependencyConfigChange(ctx, () => saveResponsiveFooterPatch(patch));
483
532
  },
484
533
  setIconMode(mode: IconMode) {
485
534
  currentConfig = saveIconsModePatch(mode);
@@ -30,6 +30,7 @@ export function createProjectRefreshScheduler<T>(
30
30
  ): ProjectRefreshScheduler<T> {
31
31
  let refreshInFlight = false;
32
32
  let refreshPending = false;
33
+ let pendingForce = false;
33
34
  let pendingTarget: T | undefined;
34
35
  let delayedRefresh: ReturnType<typeof setTimeout> | undefined;
35
36
  let lastRefreshStartedAt: number | undefined;
@@ -41,10 +42,11 @@ export function createProjectRefreshScheduler<T>(
41
42
  delayedRefresh = undefined;
42
43
  };
43
44
 
44
- const runRefresh = (target: T) => {
45
+ const runRefresh = (target: T, options: ScheduleProjectRefreshOptions = {}) => {
45
46
  clearDelayedRefresh();
46
47
  if (refreshInFlight) {
47
48
  refreshPending = true;
49
+ pendingForce ||= options.force === true;
48
50
  pendingTarget = target;
49
51
  return;
50
52
  }
@@ -60,16 +62,18 @@ export function createProjectRefreshScheduler<T>(
60
62
  afterRefresh();
61
63
  if (refreshPending) {
62
64
  refreshPending = false;
65
+ const nextForce = pendingForce;
66
+ pendingForce = false;
63
67
  const nextTarget = pendingTarget ?? target;
64
68
  pendingTarget = undefined;
65
- schedule(nextTarget);
69
+ schedule(nextTarget, { force: nextForce });
66
70
  }
67
71
  });
68
72
  };
69
73
 
70
74
  const schedule = (target: T, options: ScheduleProjectRefreshOptions = {}) => {
71
75
  if (options.force || throttleMs <= 0 || lastRefreshStartedAt === undefined) {
72
- runRefresh(target);
76
+ runRefresh(target, options);
73
77
  return;
74
78
  }
75
79
 
@@ -97,6 +101,7 @@ export function createProjectRefreshScheduler<T>(
97
101
  clearDelayedRefresh();
98
102
  refreshInFlight = false;
99
103
  refreshPending = false;
104
+ pendingForce = false;
100
105
  pendingTarget = undefined;
101
106
  lastRefreshStartedAt = undefined;
102
107
  },
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  type ColorSource,
14
14
  type ColorSourcesConfig,
15
+ type CompactFooterMaxLines,
15
16
  type ContextStyle,
16
17
  type ExtensionStatusColorMode,
17
18
  type ExtensionStatusPlacement,
@@ -50,6 +51,7 @@ const pathDisplayModeValues: PathDisplayMode[] = ["basename", "full"];
50
51
  const pathDepthValues = ["0", "1", "2", "3", "4", "5"] as const;
51
52
  const branchLengthPresetValues = ["full", "10", "20", "30", "40", "50"] as const;
52
53
  const iconModeValues: IconMode[] = ["auto", "nerd", "ascii"];
54
+ const compactFooterMaxLineValues = ["1", "2", "3", "unlimited"] as const;
53
55
  type FeatureState = "enabled" | "disabled";
54
56
 
55
57
  const featureStateValues: FeatureState[] = ["enabled", "disabled"];
@@ -66,6 +68,8 @@ type FeatureSettingId = keyof UiFeaturesConfig;
66
68
  type FooterSegmentSettingId = keyof FooterSegmentsConfig;
67
69
  type SettingsSection = (typeof settingsSections)[number];
68
70
  type LayoutSettingId =
71
+ | "responsiveFooter"
72
+ | "compactFooterMaxLines"
69
73
  | "contextStyle"
70
74
  | "separator"
71
75
  | "pathDisplay"
@@ -81,8 +85,12 @@ type SettingsCommandDeps = {
81
85
  patch: Partial<UiFeaturesConfig>,
82
86
  ctx: ExtensionContext,
83
87
  ) => { applied: boolean; reason?: string };
84
- setFooterSegments: (patch: Partial<FooterSegmentsConfig>) => void;
85
- setFooterFormat: (value: string) => void;
88
+ setFooterSegments: (patch: Partial<FooterSegmentsConfig>, ctx: ExtensionContext) => void;
89
+ setFooterFormat: (value: string, ctx: ExtensionContext) => void;
90
+ setResponsiveFooter?: (
91
+ patch: Partial<Pick<PolishedTuiConfig, "responsiveFooter" | "compactFooterMaxLines">>,
92
+ ctx: ExtensionContext,
93
+ ) => void;
86
94
  setIconMode: (mode: IconMode) => void;
87
95
  setContextStyle: (style: ContextStyle) => void;
88
96
  setSeparator: (separator: SeparatorStyle) => void;
@@ -256,8 +264,19 @@ function branchLengthValues(maxLength: GitBranchMaxLength): string[] {
256
264
  : [current, ...branchLengthPresetValues];
257
265
  }
258
266
 
267
+ function isCompactFooterMaxLines(value: string): value is `${CompactFooterMaxLines}` {
268
+ return (compactFooterMaxLineValues as readonly string[]).includes(value);
269
+ }
270
+
271
+ function parseCompactFooterMaxLines(value: string): CompactFooterMaxLines | undefined {
272
+ if (!isCompactFooterMaxLines(value)) return undefined;
273
+ return value === "unlimited" ? value : (Number(value) as 1 | 2 | 3);
274
+ }
275
+
259
276
  function isLayoutSettingId(value: string): value is LayoutSettingId {
260
277
  return (
278
+ value === "responsiveFooter" ||
279
+ value === "compactFooterMaxLines" ||
261
280
  value === "contextStyle" ||
262
281
  value === "separator" ||
263
282
  value === "pathDisplay" ||
@@ -464,6 +483,21 @@ function buildItems(
464
483
 
465
484
  if (section === "layout") {
466
485
  return [
486
+ {
487
+ id: "responsiveFooter",
488
+ label: "Responsive footer",
489
+ description: "Reflow complete content, then use the compact template when space is tight.",
490
+ currentValue: featureValue(config.responsiveFooter),
491
+ values: featureStateValues,
492
+ },
493
+ {
494
+ id: "compactFooterMaxLines",
495
+ label: "Compact footer rows",
496
+ description:
497
+ "Maximum compact rows before remaining template content is cropped with an ellipsis.",
498
+ currentValue: String(config.compactFooterMaxLines),
499
+ values: [...compactFooterMaxLineValues],
500
+ },
467
501
  {
468
502
  id: "contextStyle",
469
503
  label: "Context style",
@@ -605,7 +639,7 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
605
639
  const formatCommand = parseFormatCommand(args);
606
640
  if (formatCommand) {
607
641
  try {
608
- deps.setFooterFormat(formatCommand.value ?? "");
642
+ deps.setFooterFormat(formatCommand.value ?? "", ctx);
609
643
  deps.requestRender();
610
644
  if (ctx.hasUI) {
611
645
  if (formatCommand.value === undefined) {
@@ -719,6 +753,26 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
719
753
  }
720
754
 
721
755
  if (isLayoutSettingId(id)) {
756
+ if (id === "responsiveFooter" && isFeatureState(newValue)) {
757
+ deps.setResponsiveFooter?.({ responsiveFooter: newValue === "enabled" }, ctx);
758
+ settingsList.updateValue(id, newValue);
759
+ deps.requestRender();
760
+ ctx.ui.notify(`Responsive footer: ${newValue}`, "info");
761
+ tui.requestRender();
762
+ return;
763
+ }
764
+
765
+ if (id === "compactFooterMaxLines") {
766
+ const maxLines = parseCompactFooterMaxLines(newValue);
767
+ if (maxLines === undefined) return;
768
+ deps.setResponsiveFooter?.({ compactFooterMaxLines: maxLines }, ctx);
769
+ settingsList.updateValue(id, newValue);
770
+ deps.requestRender();
771
+ ctx.ui.notify(`Compact footer rows: ${newValue}`, "info");
772
+ tui.requestRender();
773
+ return;
774
+ }
775
+
722
776
  if (id === "contextStyle" && isContextStyle(newValue)) {
723
777
  deps.setContextStyle(newValue);
724
778
  settingsList.updateValue(id, newValue);
@@ -778,7 +832,7 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
778
832
 
779
833
  const footerSegmentSetting = footerSegmentSettingFromId(id);
780
834
  if (footerSegmentSetting && isFeatureState(newValue)) {
781
- deps.setFooterSegments(footerSegmentPatch(footerSegmentSetting, newValue));
835
+ deps.setFooterSegments(footerSegmentPatch(footerSegmentSetting, newValue), ctx);
782
836
  settingsList.updateValue(id, newValue);
783
837
  deps.requestRender();
784
838
  ctx.ui.notify(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",