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 +7 -1
- package/extensions/zentui/config.ts +43 -0
- package/extensions/zentui/footer-format.ts +85 -0
- package/extensions/zentui/footer-layout.ts +122 -0
- package/extensions/zentui/footer.ts +148 -15
- package/extensions/zentui/index.ts +79 -30
- package/extensions/zentui/project-refresh.ts +8 -3
- package/extensions/zentui/settings-command.ts +58 -4
- package/package.json +1 -1
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 {
|
|
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 =
|
|
203
|
-
|
|
204
|
-
|
|
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(
|
|
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
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
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
|
-
((
|
|
141
|
-
const wantMetrics =
|
|
142
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
+
setFooterSegments(patch: Partial<FooterSegmentsConfig>, ctx: ExtensionContext) {
|
|
522
|
+
applyFooterDependencyConfigChange(ctx, () => saveFooterSegmentsPatch(patch));
|
|
480
523
|
},
|
|
481
|
-
setFooterFormat(value: string) {
|
|
482
|
-
|
|
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
|
|
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(
|