pi-zentui 0.13.0 → 0.15.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 +28 -9
- package/extensions/zentui/config.ts +108 -1
- 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 +108 -30
- package/extensions/zentui/project-refresh.ts +8 -3
- package/extensions/zentui/settings-command.ts +334 -88
- package/extensions/zentui/ui.ts +76 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -130,7 +130,16 @@ pi install git:github.com/lmilojevicc/pi-zentui
|
|
|
130
130
|
|
|
131
131
|
User config lives at `~/.pi/agent/zentui.json`. The file is optional: missing or invalid known values fall back to Zentui defaults, unknown keys are ignored at runtime, and `/zentui` can patch color-source settings, UI feature toggles, built-in footer segment visibility, and active third-party status placements.
|
|
132
132
|
|
|
133
|
-
The interactive `/zentui` menu is split into
|
|
133
|
+
The interactive `/zentui` menu is split into exactly six sections, in this order. Use `Tab` and `Shift+Tab` to switch sections. Every listed control patches the shown JSON equivalent:
|
|
134
|
+
|
|
135
|
+
1. **Appearance** — Starship/footer colors (`colorSources.starship`); editor + previous-message colors (`colorSources.editor` and `colorSources.userMessages`); separator (`separator`); icon mode (`icons.mode`).
|
|
136
|
+
2. **Editor** — editor enabled (`features.editor`); editor model label (`editorModelLabel`); copy-friendly mode (`features.copyFriendly`); viewport indicators (`features.viewportIndicators`); fixed editor (`fixedEditor.enabled`); and, while fixed editor is enabled, mouse scroll (`fixedEditor.mouseScroll`) and copy notice (`fixedEditor.copyNotice`).
|
|
137
|
+
3. **Footer** — status line enabled (`features.statusLine`); responsive footer (`responsiveFooter`); compact footer rows (`compactFooterMaxLines`); context style (`contextStyle`); path display (`pathDisplay.mode`); path depth (`pathDisplay.depth`).
|
|
138
|
+
4. **Segments** — visibility toggles for every non-Git built-in segment under `footerSegments`: `cwd`, `sessionName`, `runtime`, `context`, `tokens`, `cost`, `sessionDuration`, `username`, `time`, `os`, and `packageVersion`.
|
|
139
|
+
5. **Git** — Git branch visibility (`footerSegments.gitBranch`); branch length (`gitBranch.maxLength`); Git status visibility (`footerSegments.gitStatus`); Git counts visibility (`footerSegments.gitCounts`); Git commit visibility (`footerSegments.gitCommit`); commit-only-detached (`gitCommit.onlyDetached`); exact-match tag (`gitCommit.showTag`); Git metrics visibility (`footerSegments.gitMetrics`); hide zero metrics (`gitMetrics.onlyNonzero`); ignore submodules (`gitMetrics.ignoreSubmodules`).
|
|
140
|
+
6. **Extensions** — default placement (`extensionStatuses.defaultPlacement`) first, followed by placement (`extensionStatuses.placements[key]`) and color (`extensionStatuses.colorModes[key]`) controls for currently active status keys.
|
|
141
|
+
|
|
142
|
+
Free-form values such as custom formats, raw colors/styles, numeric values outside the shown presets, and inactive extension keys remain JSON-only.
|
|
134
143
|
|
|
135
144
|
Useful slash-command shortcuts:
|
|
136
145
|
|
|
@@ -144,6 +153,9 @@ Useful slash-command shortcuts:
|
|
|
144
153
|
/zentui copy-friendly enable
|
|
145
154
|
/zentui copy-friendly disable
|
|
146
155
|
/zentui copy-friendly toggle
|
|
156
|
+
/zentui viewport-indicators enable
|
|
157
|
+
/zentui viewport-indicators disable
|
|
158
|
+
/zentui viewport-indicators toggle
|
|
147
159
|
/zentui fixed-editor enable
|
|
148
160
|
/zentui fixed-editor disable
|
|
149
161
|
/zentui fixed-editor toggle
|
|
@@ -157,6 +169,9 @@ Default config values — copy this and change any value you want:
|
|
|
157
169
|
{
|
|
158
170
|
"projectRefreshIntervalMs": 30000,
|
|
159
171
|
"footerFormat": "",
|
|
172
|
+
"responsiveFooter": true,
|
|
173
|
+
"compactFooterFormat": "$cwd$wrap(in $session_name)$wrap(on $git_branch) $git_status$wrap$context$wrap_sep$tokens",
|
|
174
|
+
"compactFooterMaxLines": 2,
|
|
160
175
|
"editorMetadataFormat": "$model $provider( $thinking)",
|
|
161
176
|
"separator": "pipe",
|
|
162
177
|
"contextStyle": "text",
|
|
@@ -235,7 +250,8 @@ Default config values — copy this and change any value you want:
|
|
|
235
250
|
"features": {
|
|
236
251
|
"editor": true,
|
|
237
252
|
"statusLine": true,
|
|
238
|
-
"copyFriendly": false
|
|
253
|
+
"copyFriendly": false,
|
|
254
|
+
"viewportIndicators": true
|
|
239
255
|
},
|
|
240
256
|
"footerSegments": {
|
|
241
257
|
"cwd": true,
|
|
@@ -282,18 +298,21 @@ Default config values — copy this and change any value you want:
|
|
|
282
298
|
- `contextStyle`: `text` (default), `gauge`, or `text+gauge` for the context segment. Context usage refreshes during assistant streaming; token and cost totals remain canonical and finalize at turn boundaries.
|
|
283
299
|
- `editorModelLabel`: controls the model shown in the editor frame. `id` (default) shows the model id; `name` shows the model's display name (including custom `name` values set in `models.json`), falling back to the id when no name is set.
|
|
284
300
|
- `editorMetadataFormat`: JSON-only template for the left side of the editor metadata row. Missing, non-string, or empty values restore the default `$model $provider( $thinking)` layout; non-empty strings, including whitespace-only strings, are preserved. See [Editor Metadata Format](#editor-metadata-format) below.
|
|
285
|
-
- `separator`: controls the default footer layout and extension-status connectors: `pipe` (default, ` | `), `dot` (` · `), `chevron` (` › `), or `none` (one space). Cycle it from the `/zentui` **
|
|
301
|
+
- `separator`: controls the default footer layout and extension-status connectors: `pipe` (default, ` | `), `dot` (` · `), `chevron` (` › `), or `none` (one space). Cycle it from the `/zentui` **Appearance** tab. This selects the separator glyph; `colors.separator` controls its color. Custom `footerFormat` literals and `$sep` keep their existing behavior.
|
|
286
302
|
- `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
|
|
287
|
-
- `pathDisplay`: controls how the cwd/`$cwd` path is shown. `mode` is `basename` (default, last segment only) or `full` (path with home contracted to `~`). In `full` mode, `depth` keeps only the last N trailing directories (`0` = entire path after `~`, max `5`); when parents are dropped the path is prefixed with `…/` (Starship-style). The `/zentui` **
|
|
288
|
-
- `gitBranch.maxLength`: visible width of the built-in branch name and `$git_branch` / `$branch`. The default `full` preserves the complete name; any positive integer uses that width including the trailing `…`. `/zentui` **
|
|
303
|
+
- `pathDisplay`: controls how the cwd/`$cwd` path is shown. `mode` is `basename` (default, last segment only) or `full` (path with home contracted to `~`). In `full` mode, `depth` keeps only the last N trailing directories (`0` = entire path after `~`, max `5`); when parents are dropped the path is prefixed with `…/` (Starship-style). The `/zentui` **Footer** tab cycles path mode and path depth (`0`–`5`; depth is ignored for basename). Example: `~/Projects/foo/bar` with `depth: 2` → `…/foo/bar`.
|
|
304
|
+
- `gitBranch.maxLength`: visible width of the built-in branch name and `$git_branch` / `$branch`. The default `full` preserves the complete name; any positive integer uses that width including the trailing `…`. `/zentui` **Git** cycles `full`, `10`, `20`, `30`, `40`, and `50`; custom positive integers can be set in JSON.
|
|
289
305
|
- `icons`: every shown icon key is configurable; omit any key to use the Zentui default. `icons.mode` is `auto` | `nerd` | `ascii` (default `auto`, same glyphs as nerd). ASCII mode swaps in plain fallbacks for statusline icons and runtime symbols — useful without a Nerd Font. Custom per-icon strings always win over mode defaults. Custom `icons.os` always wins; when left at the mode default, Zentui maps the OS icon by platform. `rail` sets the vertical glyph drawn as the left rail of the active editor frame and previous user messages when `copyFriendly` is disabled (default `│`; any single Unicode vertical or block glyph). `editorPrompt` controls an optional copy-friendly editor prompt glyph; the default is `""` so copy-friendly mode stays rail-free.
|
|
290
306
|
- `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
|
|
291
|
-
- `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
|
|
292
|
-
- `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
|
|
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` **
|
|
307
|
+
- `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. `viewportIndicators` preserves Pi's native `↑ N more` / `↓ N more` wrapped-row counts in Zentui's editor borders (default `true`). All four can be changed from `/zentui` or direct slash-command arguments.
|
|
308
|
+
- `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 non-Git segments from **Segments** and Git segments from **Git** in `/zentui`.
|
|
309
|
+
- `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` **Footer** tab configures responsive behavior, compact rows, context style, and path display mode/depth; **Appearance** configures separator and icon mode; **Git** configures branch length; set or clear custom formats with `/zentui format`.
|
|
310
|
+
- `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.
|
|
311
|
+
- `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.
|
|
312
|
+
- `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
313
|
- `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
314
|
- `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
|
-
- `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
|
|
315
|
+
- `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 **Extensions** tab in `/zentui` lists only statuses that are currently active. `defaultPlacement` applies only when a status key has no entry in `placements`; keyed overrides always win.
|
|
297
316
|
- The shown `editor*` values match the default `theme` source. Omit those keys to keep Zentui's source-aware defaults when switching between `theme` and `terminal`.
|
|
298
317
|
- `editorAccent` styles the active editor rail and previous user-message rail when `features.copyFriendly` is disabled.
|
|
299
318
|
- `editorPrompt` styles the copy-friendly editor prompt glyph. Omit it to use `editorAccent`, then the default accent fallback.
|
|
@@ -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;
|
|
@@ -63,6 +67,7 @@ export type UiFeaturesConfig = {
|
|
|
63
67
|
editor: boolean;
|
|
64
68
|
statusLine: boolean;
|
|
65
69
|
copyFriendly: boolean;
|
|
70
|
+
viewportIndicators: boolean;
|
|
66
71
|
};
|
|
67
72
|
|
|
68
73
|
export type FooterSegmentsConfig = {
|
|
@@ -127,6 +132,9 @@ export const DEFAULT_EDITOR_METADATA_FORMAT = "$model $provider( $thinking)";
|
|
|
127
132
|
export type PolishedTuiConfig = {
|
|
128
133
|
projectRefreshIntervalMs: number;
|
|
129
134
|
footerFormat: string;
|
|
135
|
+
responsiveFooter: boolean;
|
|
136
|
+
compactFooterFormat: string;
|
|
137
|
+
compactFooterMaxLines: CompactFooterMaxLines;
|
|
130
138
|
editorMetadataFormat: string;
|
|
131
139
|
separator: SeparatorStyle;
|
|
132
140
|
contextStyle: ContextStyle;
|
|
@@ -225,6 +233,9 @@ export const configPath = join(getAgentDir(), "zentui.json");
|
|
|
225
233
|
export const defaultConfig: PolishedTuiConfig = {
|
|
226
234
|
projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
|
|
227
235
|
footerFormat: "",
|
|
236
|
+
responsiveFooter: true,
|
|
237
|
+
compactFooterFormat: DEFAULT_COMPACT_FOOTER_FORMAT,
|
|
238
|
+
compactFooterMaxLines: 2,
|
|
228
239
|
editorMetadataFormat: DEFAULT_EDITOR_METADATA_FORMAT,
|
|
229
240
|
separator: "pipe",
|
|
230
241
|
contextStyle: "text",
|
|
@@ -267,6 +278,7 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
267
278
|
editor: true,
|
|
268
279
|
statusLine: true,
|
|
269
280
|
copyFriendly: false,
|
|
281
|
+
viewportIndicators: true,
|
|
270
282
|
},
|
|
271
283
|
footerSegments: {
|
|
272
284
|
cwd: true,
|
|
@@ -399,6 +411,10 @@ function stringValue(record: Record<string, unknown>, key: string): string | und
|
|
|
399
411
|
return typeof value === "string" ? value : undefined;
|
|
400
412
|
}
|
|
401
413
|
|
|
414
|
+
function parseCompactFooterMaxLines(value: unknown): CompactFooterMaxLines {
|
|
415
|
+
return value === 1 || value === 2 || value === 3 || value === "unlimited" ? value : 2;
|
|
416
|
+
}
|
|
417
|
+
|
|
402
418
|
function colorValue(record: Record<string, unknown>, key: string): string | undefined {
|
|
403
419
|
const value = stringValue(record, key);
|
|
404
420
|
return value !== undefined && isSupportedColorSpec(value) ? value : undefined;
|
|
@@ -493,6 +509,7 @@ function normalizeUiFeatures(record: Record<string, unknown>): UiFeaturesConfig
|
|
|
493
509
|
editor: booleanValue(record, "editor"),
|
|
494
510
|
statusLine: booleanValue(record, "statusLine"),
|
|
495
511
|
copyFriendly: booleanValue(record, "copyFriendly"),
|
|
512
|
+
viewportIndicators: booleanValue(record, "viewportIndicators"),
|
|
496
513
|
};
|
|
497
514
|
}
|
|
498
515
|
|
|
@@ -605,7 +622,12 @@ function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
|
|
|
605
622
|
}
|
|
606
623
|
|
|
607
624
|
function isUiFeatureKey(value: string): value is keyof UiFeaturesConfig {
|
|
608
|
-
return
|
|
625
|
+
return (
|
|
626
|
+
value === "editor" ||
|
|
627
|
+
value === "statusLine" ||
|
|
628
|
+
value === "copyFriendly" ||
|
|
629
|
+
value === "viewportIndicators"
|
|
630
|
+
);
|
|
609
631
|
}
|
|
610
632
|
|
|
611
633
|
function isFooterSegmentKey(value: string): value is keyof FooterSegmentsConfig {
|
|
@@ -773,9 +795,19 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
|
773
795
|
? normalizeFixedEditorConfig(config.fixedEditor as Record<string, unknown>)
|
|
774
796
|
: defaultConfig.fixedEditor;
|
|
775
797
|
const editorMetadataFormat = stringValue(config, "editorMetadataFormat");
|
|
798
|
+
const compactFooterFormat = stringValue(config, "compactFooterFormat");
|
|
776
799
|
return {
|
|
777
800
|
projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
|
|
778
801
|
footerFormat: stringValue(config, "footerFormat") ?? "",
|
|
802
|
+
responsiveFooter:
|
|
803
|
+
typeof config.responsiveFooter === "boolean"
|
|
804
|
+
? config.responsiveFooter
|
|
805
|
+
: defaultConfig.responsiveFooter,
|
|
806
|
+
compactFooterFormat:
|
|
807
|
+
compactFooterFormat && compactFooterFormat.length > 0
|
|
808
|
+
? compactFooterFormat
|
|
809
|
+
: DEFAULT_COMPACT_FOOTER_FORMAT,
|
|
810
|
+
compactFooterMaxLines: parseCompactFooterMaxLines(config.compactFooterMaxLines),
|
|
779
811
|
editorMetadataFormat:
|
|
780
812
|
editorMetadataFormat && editorMetadataFormat.length > 0
|
|
781
813
|
? editorMetadataFormat
|
|
@@ -879,6 +911,25 @@ export function saveFooterFormatPatch(value: string, path = configPath): Polishe
|
|
|
879
911
|
});
|
|
880
912
|
}
|
|
881
913
|
|
|
914
|
+
export function saveResponsiveFooterPatch(
|
|
915
|
+
patch: Partial<
|
|
916
|
+
Pick<PolishedTuiConfig, "responsiveFooter" | "compactFooterFormat" | "compactFooterMaxLines">
|
|
917
|
+
>,
|
|
918
|
+
path = configPath,
|
|
919
|
+
): PolishedTuiConfig {
|
|
920
|
+
return mutateConfig(path, (record) => {
|
|
921
|
+
if (typeof patch.responsiveFooter === "boolean") {
|
|
922
|
+
record.responsiveFooter = patch.responsiveFooter;
|
|
923
|
+
}
|
|
924
|
+
if (typeof patch.compactFooterFormat === "string") {
|
|
925
|
+
record.compactFooterFormat = patch.compactFooterFormat;
|
|
926
|
+
}
|
|
927
|
+
if (patch.compactFooterMaxLines !== undefined) {
|
|
928
|
+
record.compactFooterMaxLines = parseCompactFooterMaxLines(patch.compactFooterMaxLines);
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
882
933
|
export function saveIconsModePatch(mode: IconMode, path = configPath): PolishedTuiConfig {
|
|
883
934
|
return mutateConfig(path, (record) => {
|
|
884
935
|
const existing = isRecord(record.icons) ? { ...(record.icons as Record<string, unknown>) } : {};
|
|
@@ -947,6 +998,62 @@ export function saveGitBranchPatch(
|
|
|
947
998
|
});
|
|
948
999
|
}
|
|
949
1000
|
|
|
1001
|
+
export function saveEditorModelLabel(
|
|
1002
|
+
value: ModelLabelSource,
|
|
1003
|
+
path = configPath,
|
|
1004
|
+
): PolishedTuiConfig {
|
|
1005
|
+
return mutateConfig(path, (record) => {
|
|
1006
|
+
record.editorModelLabel = parseEditorModelLabel(value);
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export function saveGitCommitPatch(
|
|
1011
|
+
patch: Partial<Pick<GitCommitConfig, "onlyDetached" | "showTag">>,
|
|
1012
|
+
path = configPath,
|
|
1013
|
+
): PolishedTuiConfig {
|
|
1014
|
+
return mutateConfig(path, (record) => {
|
|
1015
|
+
const existing = isRecord(record.gitCommit)
|
|
1016
|
+
? { ...(record.gitCommit as Record<string, unknown>) }
|
|
1017
|
+
: {};
|
|
1018
|
+
if (typeof patch.onlyDetached === "boolean") existing.onlyDetached = patch.onlyDetached;
|
|
1019
|
+
if (typeof patch.showTag === "boolean") existing.showTag = patch.showTag;
|
|
1020
|
+
record.gitCommit = existing;
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
export function saveGitMetricsPatch(
|
|
1025
|
+
patch: Partial<GitMetricsConfig>,
|
|
1026
|
+
path = configPath,
|
|
1027
|
+
): PolishedTuiConfig {
|
|
1028
|
+
return mutateConfig(path, (record) => {
|
|
1029
|
+
const existing = isRecord(record.gitMetrics)
|
|
1030
|
+
? { ...(record.gitMetrics as Record<string, unknown>) }
|
|
1031
|
+
: {};
|
|
1032
|
+
if (typeof patch.onlyNonzero === "boolean") existing.onlyNonzero = patch.onlyNonzero;
|
|
1033
|
+
if (typeof patch.ignoreSubmodules === "boolean") {
|
|
1034
|
+
existing.ignoreSubmodules = patch.ignoreSubmodules;
|
|
1035
|
+
}
|
|
1036
|
+
record.gitMetrics = existing;
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
export function saveExtensionStatusDefaultPlacement(
|
|
1041
|
+
placement: ExtensionStatusPlacement,
|
|
1042
|
+
path = configPath,
|
|
1043
|
+
): PolishedTuiConfig {
|
|
1044
|
+
return mutateConfig(path, (record) => {
|
|
1045
|
+
const existing = isRecord(record.extensionStatuses)
|
|
1046
|
+
? { ...(record.extensionStatuses as Record<string, unknown>) }
|
|
1047
|
+
: {};
|
|
1048
|
+
record.extensionStatuses = {
|
|
1049
|
+
...existing,
|
|
1050
|
+
defaultPlacement: isExtensionStatusPlacement(placement)
|
|
1051
|
+
? placement
|
|
1052
|
+
: defaultConfig.extensionStatuses.defaultPlacement,
|
|
1053
|
+
};
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
950
1057
|
export function saveExtensionStatusPlacement(
|
|
951
1058
|
key: string,
|
|
952
1059
|
placement: ExtensionStatusPlacement,
|
|
@@ -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
|
+
}
|