pi-zentui 0.12.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 +36 -1
- package/extensions/zentui/config.ts +60 -0
- package/extensions/zentui/editor-metadata-format.ts +259 -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 +86 -31
- package/extensions/zentui/project-refresh.ts +8 -3
- package/extensions/zentui/settings-command.ts +58 -4
- package/extensions/zentui/state.ts +15 -2
- package/extensions/zentui/ui.ts +100 -126
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -157,8 +157,13 @@ 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,
|
|
163
|
+
"editorMetadataFormat": "$model $provider( $thinking)",
|
|
160
164
|
"separator": "pipe",
|
|
161
165
|
"contextStyle": "text",
|
|
166
|
+
"editorModelLabel": "id",
|
|
162
167
|
"contextThresholds": {
|
|
163
168
|
"warning": 70,
|
|
164
169
|
"error": 90
|
|
@@ -278,6 +283,8 @@ Default config values — copy this and change any value you want:
|
|
|
278
283
|
- Style values can be Starship/terminal strings (`bold purple`, `fg:202`, `#89b` / `#89b4fa`, `bg:blue fg:bright-green`) or Pi theme tokens (`accent`, `borderMuted`, `thinkingHigh`). Short `#rgb` hex values expand to `#rrggbb`.
|
|
279
284
|
- `projectRefreshIntervalMs`: project status polling interval; `0` disables polling. Values `1..4999` clamp up to `5000` (minimum 5s); invalid/non-finite values fall back to `30000`.
|
|
280
285
|
- `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.
|
|
286
|
+
- `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.
|
|
287
|
+
- `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.
|
|
281
288
|
- `separator`: controls the default footer layout and extension-status connectors: `pipe` (default, ` | `), `dot` (` · `), `chevron` (` › `), or `none` (one space). Cycle it from the `/zentui` **Layout** tab. This selects the separator glyph; `colors.separator` controls its color. Custom `footerFormat` literals and `$sep` keep their existing behavior.
|
|
282
289
|
- `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
|
|
283
290
|
- `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` **Layout** tab cycles path mode and path depth (`0`–`5`; depth is ignored for basename). Example: `~/Projects/foo/bar` with `depth: 2` → `…/foo/bar`.
|
|
@@ -286,7 +293,10 @@ Default config values — copy this and change any value you want:
|
|
|
286
293
|
- `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
|
|
287
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.
|
|
288
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`.
|
|
289
|
-
- `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.
|
|
290
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.
|
|
291
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.
|
|
292
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.
|
|
@@ -298,6 +308,31 @@ Default config values — copy this and change any value you want:
|
|
|
298
308
|
|
|
299
309
|
Tip: when using copy-friendly mode, setting Pi's `editorPaddingX` to `1` in `~/.pi/agent/settings.json` keeps a small left gutter without copying a rail glyph.
|
|
300
310
|
|
|
311
|
+
## Editor Metadata Format
|
|
312
|
+
|
|
313
|
+
Set `editorMetadataFormat` in `~/.pi/agent/zentui.json` to customize the left side of the editor metadata row:
|
|
314
|
+
|
|
315
|
+
```json
|
|
316
|
+
{
|
|
317
|
+
"editorMetadataFormat": "$model_name ($model_id)( · $provider)( · $thinking)( · $session_name)"
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
The syntax follows the relevant `footerFormat` conventions: `$variable` and `${variable}` references, literal text and spaces, and conditional groups `( ... )` that disappear when all variables inside are empty. Unknown variables and `$fill` render empty; `$fill` never creates an editor layout zone because the right side remains reserved for structural Vim status.
|
|
322
|
+
|
|
323
|
+
| Token | Renders |
|
|
324
|
+
| --------------- | --------------------------------------------------------------------------------------------- |
|
|
325
|
+
| `$model` | label selected by `editorModelLabel` (`id`, or name with ID fallback) |
|
|
326
|
+
| `$model_id` | active Pi model ID |
|
|
327
|
+
| `$model_name` | active Pi model display name; empty when no name is set |
|
|
328
|
+
| `$provider` | provider label using Zentui's existing formatting |
|
|
329
|
+
| `$thinking` | current thinking level; empty when thinking is `off` |
|
|
330
|
+
| `$session_name` | current Pi session name; empty when unnamed |
|
|
331
|
+
|
|
332
|
+
Model variables use `editorModel`, provider uses `editorProvider`, and thinking uses the matching `editorThinking*` style. Literal text and `$session_name` use the neutral editor border theme style. The template controls spacing. ANSI/VT sequences, control characters, and line-breaking whitespace are sanitized before rendering without collapsing ordinary spaces.
|
|
333
|
+
|
|
334
|
+
Missing, non-string, or empty values use the default `$model $provider( $thinking)`. A non-empty format that resolves to no visible metadata keeps the normal blank spacer and metadata rows so the editor frame height remains stable. This option is configured only through JSON in its first version; `/zentui format` continues to control the footer only.
|
|
335
|
+
|
|
301
336
|
## Footer Format Template
|
|
302
337
|
|
|
303
338
|
For full control, set a Starship-style `footerFormat` template string. It supports `$variable` and `${variable}` tokens, a special `$fill` token that splits the line into left and right zones, and conditional groups `( ... )` that drop entirely when every nested variable is empty. When set, it overrides the built-in `footerSegments` layout; when empty or omitted, the segment layout above is used.
|
|
@@ -32,6 +32,11 @@ export type { IconMode } from "./icons";
|
|
|
32
32
|
|
|
33
33
|
export type ContextStyle = "text" | "gauge" | "text+gauge";
|
|
34
34
|
export type SeparatorStyle = "pipe" | "dot" | "chevron" | "none";
|
|
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";
|
|
35
40
|
|
|
36
41
|
export type ContextThresholds = {
|
|
37
42
|
warning: number;
|
|
@@ -121,12 +126,18 @@ export type ExtensionStatusesConfig = {
|
|
|
121
126
|
|
|
122
127
|
const DEFAULT_PROJECT_REFRESH_INTERVAL_MS = 30_000;
|
|
123
128
|
const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
|
|
129
|
+
export const DEFAULT_EDITOR_METADATA_FORMAT = "$model $provider( $thinking)";
|
|
124
130
|
|
|
125
131
|
export type PolishedTuiConfig = {
|
|
126
132
|
projectRefreshIntervalMs: number;
|
|
127
133
|
footerFormat: string;
|
|
134
|
+
responsiveFooter: boolean;
|
|
135
|
+
compactFooterFormat: string;
|
|
136
|
+
compactFooterMaxLines: CompactFooterMaxLines;
|
|
137
|
+
editorMetadataFormat: string;
|
|
128
138
|
separator: SeparatorStyle;
|
|
129
139
|
contextStyle: ContextStyle;
|
|
140
|
+
editorModelLabel: ModelLabelSource;
|
|
130
141
|
contextThresholds: ContextThresholds;
|
|
131
142
|
pathDisplay: PathDisplayConfig;
|
|
132
143
|
gitBranch: GitBranchConfig;
|
|
@@ -221,8 +232,13 @@ export const configPath = join(getAgentDir(), "zentui.json");
|
|
|
221
232
|
export const defaultConfig: PolishedTuiConfig = {
|
|
222
233
|
projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
|
|
223
234
|
footerFormat: "",
|
|
235
|
+
responsiveFooter: true,
|
|
236
|
+
compactFooterFormat: DEFAULT_COMPACT_FOOTER_FORMAT,
|
|
237
|
+
compactFooterMaxLines: 2,
|
|
238
|
+
editorMetadataFormat: DEFAULT_EDITOR_METADATA_FORMAT,
|
|
224
239
|
separator: "pipe",
|
|
225
240
|
contextStyle: "text",
|
|
241
|
+
editorModelLabel: "id",
|
|
226
242
|
contextThresholds: { warning: 70, error: 90 },
|
|
227
243
|
pathDisplay: { mode: "basename", depth: 0 },
|
|
228
244
|
gitBranch: { maxLength: "full" },
|
|
@@ -327,6 +343,11 @@ function parseContextStyle(value: unknown): ContextStyle {
|
|
|
327
343
|
return defaultConfig.contextStyle;
|
|
328
344
|
}
|
|
329
345
|
|
|
346
|
+
function parseEditorModelLabel(value: unknown): ModelLabelSource {
|
|
347
|
+
if (value === "id" || value === "name") return value;
|
|
348
|
+
return defaultConfig.editorModelLabel;
|
|
349
|
+
}
|
|
350
|
+
|
|
330
351
|
export function isSeparatorStyle(value: unknown): value is SeparatorStyle {
|
|
331
352
|
return value === "pipe" || value === "dot" || value === "chevron" || value === "none";
|
|
332
353
|
}
|
|
@@ -388,6 +409,10 @@ function stringValue(record: Record<string, unknown>, key: string): string | und
|
|
|
388
409
|
return typeof value === "string" ? value : undefined;
|
|
389
410
|
}
|
|
390
411
|
|
|
412
|
+
function parseCompactFooterMaxLines(value: unknown): CompactFooterMaxLines {
|
|
413
|
+
return value === 1 || value === 2 || value === 3 || value === "unlimited" ? value : 2;
|
|
414
|
+
}
|
|
415
|
+
|
|
391
416
|
function colorValue(record: Record<string, unknown>, key: string): string | undefined {
|
|
392
417
|
const value = stringValue(record, key);
|
|
393
418
|
return value !== undefined && isSupportedColorSpec(value) ? value : undefined;
|
|
@@ -761,11 +786,27 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
|
761
786
|
const fixedEditor = isRecord(config.fixedEditor)
|
|
762
787
|
? normalizeFixedEditorConfig(config.fixedEditor as Record<string, unknown>)
|
|
763
788
|
: defaultConfig.fixedEditor;
|
|
789
|
+
const editorMetadataFormat = stringValue(config, "editorMetadataFormat");
|
|
790
|
+
const compactFooterFormat = stringValue(config, "compactFooterFormat");
|
|
764
791
|
return {
|
|
765
792
|
projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
|
|
766
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),
|
|
803
|
+
editorMetadataFormat:
|
|
804
|
+
editorMetadataFormat && editorMetadataFormat.length > 0
|
|
805
|
+
? editorMetadataFormat
|
|
806
|
+
: DEFAULT_EDITOR_METADATA_FORMAT,
|
|
767
807
|
separator: parseSeparatorStyle(config.separator),
|
|
768
808
|
contextStyle: parseContextStyle(config.contextStyle),
|
|
809
|
+
editorModelLabel: parseEditorModelLabel(config.editorModelLabel),
|
|
769
810
|
contextThresholds: parseContextThresholds(config.contextThresholds),
|
|
770
811
|
pathDisplay: parsePathDisplay(config.pathDisplay),
|
|
771
812
|
gitBranch,
|
|
@@ -862,6 +903,25 @@ export function saveFooterFormatPatch(value: string, path = configPath): Polishe
|
|
|
862
903
|
});
|
|
863
904
|
}
|
|
864
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
|
+
|
|
865
925
|
export function saveIconsModePatch(mode: IconMode, path = configPath): PolishedTuiConfig {
|
|
866
926
|
return mutateConfig(path, (record) => {
|
|
867
927
|
const existing = isRecord(record.icons) ? { ...(record.icons as Record<string, unknown>) } : {};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { PolishedTuiConfig } from "./config";
|
|
3
|
+
import { type FormatToken, parseFooterFormat } from "./footer-format";
|
|
4
|
+
import { EDITOR_ACCENT_FALLBACK, renderStyleForSourceOrFallback, safeThemeFg } from "./style";
|
|
5
|
+
|
|
6
|
+
export type EditorMetadataValues = {
|
|
7
|
+
model: string;
|
|
8
|
+
modelId: string;
|
|
9
|
+
modelName: string;
|
|
10
|
+
provider: string;
|
|
11
|
+
thinking: string;
|
|
12
|
+
sessionName: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type RenderedTokens = {
|
|
16
|
+
styled: string;
|
|
17
|
+
hasDynamic: boolean;
|
|
18
|
+
hasNonEmptyDynamic: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const ESC = 0x1b;
|
|
22
|
+
const BEL = 0x07;
|
|
23
|
+
const CAN = 0x18;
|
|
24
|
+
const SUB = 0x1a;
|
|
25
|
+
const C1_DCS = 0x90;
|
|
26
|
+
const C1_CSI = 0x9b;
|
|
27
|
+
const C1_ST = 0x9c;
|
|
28
|
+
const C1_OSC = 0x9d;
|
|
29
|
+
const C1_SOS = 0x98;
|
|
30
|
+
const C1_PM = 0x9e;
|
|
31
|
+
const C1_APC = 0x9f;
|
|
32
|
+
|
|
33
|
+
function consumeCsi(value: string, start: number): number {
|
|
34
|
+
for (let index = start; index < value.length; index++) {
|
|
35
|
+
const code = value.charCodeAt(index);
|
|
36
|
+
if (code === CAN || code === SUB) return index + 1;
|
|
37
|
+
if (code >= 0x40 && code <= 0x7e) return index + 1;
|
|
38
|
+
}
|
|
39
|
+
return value.length;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function consumeControlString(value: string, start: number, allowBel: boolean): number {
|
|
43
|
+
for (let index = start; index < value.length; index++) {
|
|
44
|
+
const code = value.charCodeAt(index);
|
|
45
|
+
if (code === CAN || code === SUB) return index + 1;
|
|
46
|
+
if (allowBel && code === BEL) return index + 1;
|
|
47
|
+
if (code === C1_ST) return index + 1;
|
|
48
|
+
if (code === ESC && value.charCodeAt(index + 1) === 0x5c) return index + 2;
|
|
49
|
+
}
|
|
50
|
+
return value.length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function consumeEscape(value: string, start: number): number {
|
|
54
|
+
if (start + 1 >= value.length) return value.length;
|
|
55
|
+
const next = value.charCodeAt(start + 1);
|
|
56
|
+
if (next === 0x5b) return consumeCsi(value, start + 2);
|
|
57
|
+
if (next === 0x5d) return consumeControlString(value, start + 2, true);
|
|
58
|
+
if (next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
|
|
59
|
+
return consumeControlString(value, start + 2, false);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let index = start + 1;
|
|
63
|
+
while (index < value.length) {
|
|
64
|
+
const code = value.charCodeAt(index);
|
|
65
|
+
if (code >= 0x20 && code <= 0x2f) {
|
|
66
|
+
index += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
return code >= 0x30 && code <= 0x7e ? index + 1 : index;
|
|
70
|
+
}
|
|
71
|
+
return value.length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isNormalizedWhitespace(code: number): boolean {
|
|
75
|
+
return (
|
|
76
|
+
code === 0x09 ||
|
|
77
|
+
code === 0x0a ||
|
|
78
|
+
code === 0x0b ||
|
|
79
|
+
code === 0x0c ||
|
|
80
|
+
code === 0x0d ||
|
|
81
|
+
code === 0x85 ||
|
|
82
|
+
code === 0x2028 ||
|
|
83
|
+
code === 0x2029
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function sanitizeEditorMetadataText(value: string): string {
|
|
88
|
+
let sanitized = "";
|
|
89
|
+
for (let index = 0; index < value.length; ) {
|
|
90
|
+
const code = value.charCodeAt(index);
|
|
91
|
+
if (code === ESC) {
|
|
92
|
+
index = consumeEscape(value, index);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (code === C1_CSI) {
|
|
96
|
+
index = consumeCsi(value, index + 1);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (code === C1_OSC) {
|
|
100
|
+
index = consumeControlString(value, index + 1, true);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (code === C1_DCS || code === C1_SOS || code === C1_PM || code === C1_APC) {
|
|
104
|
+
index = consumeControlString(value, index + 1, false);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (isNormalizedWhitespace(code)) {
|
|
108
|
+
sanitized += " ";
|
|
109
|
+
do index += 1;
|
|
110
|
+
while (index < value.length && isNormalizedWhitespace(value.charCodeAt(index)));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
|
|
114
|
+
index += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
sanitized += value[index];
|
|
118
|
+
index += 1;
|
|
119
|
+
}
|
|
120
|
+
return sanitized;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function editorThinkingStyle(config: PolishedTuiConfig, level: string): string | undefined {
|
|
124
|
+
switch (level.toLowerCase()) {
|
|
125
|
+
case "minimal":
|
|
126
|
+
return config.colors.editorThinkingMinimal ?? config.colors.editorThinking;
|
|
127
|
+
case "low":
|
|
128
|
+
return config.colors.editorThinkingLow ?? config.colors.editorThinking;
|
|
129
|
+
case "medium":
|
|
130
|
+
return config.colors.editorThinkingMedium ?? config.colors.editorThinking;
|
|
131
|
+
case "high":
|
|
132
|
+
return config.colors.editorThinkingHigh ?? config.colors.editorThinking;
|
|
133
|
+
case "xhigh":
|
|
134
|
+
return config.colors.editorThinkingXhigh ?? config.colors.editorThinking;
|
|
135
|
+
default:
|
|
136
|
+
return config.colors.editorThinking;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function renderVariable(
|
|
141
|
+
name: string,
|
|
142
|
+
values: EditorMetadataValues,
|
|
143
|
+
uiTheme: Theme,
|
|
144
|
+
config: PolishedTuiConfig,
|
|
145
|
+
): { plain: string; styled: string } {
|
|
146
|
+
const colorSource = config.colorSources.editor;
|
|
147
|
+
const thinking = values.thinking.toLowerCase() === "off" ? "" : values.thinking;
|
|
148
|
+
const raw =
|
|
149
|
+
name === "model"
|
|
150
|
+
? values.model
|
|
151
|
+
: name === "model_id"
|
|
152
|
+
? values.modelId
|
|
153
|
+
: name === "model_name"
|
|
154
|
+
? values.modelName
|
|
155
|
+
: name === "provider"
|
|
156
|
+
? values.provider
|
|
157
|
+
: name === "thinking"
|
|
158
|
+
? thinking
|
|
159
|
+
: name === "session_name"
|
|
160
|
+
? values.sessionName
|
|
161
|
+
: "";
|
|
162
|
+
const plain = sanitizeEditorMetadataText(raw);
|
|
163
|
+
if (!plain) return { plain: "", styled: "" };
|
|
164
|
+
|
|
165
|
+
if (name === "model" || name === "model_id" || name === "model_name") {
|
|
166
|
+
return {
|
|
167
|
+
plain,
|
|
168
|
+
styled: renderStyleForSourceOrFallback(
|
|
169
|
+
uiTheme,
|
|
170
|
+
colorSource,
|
|
171
|
+
config.colors.editorModel,
|
|
172
|
+
EDITOR_ACCENT_FALLBACK,
|
|
173
|
+
plain,
|
|
174
|
+
),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (name === "provider") {
|
|
178
|
+
return {
|
|
179
|
+
plain,
|
|
180
|
+
styled: renderStyleForSourceOrFallback(
|
|
181
|
+
uiTheme,
|
|
182
|
+
colorSource,
|
|
183
|
+
config.colors.editorProvider,
|
|
184
|
+
"text",
|
|
185
|
+
plain,
|
|
186
|
+
),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (name === "thinking") {
|
|
190
|
+
return {
|
|
191
|
+
plain,
|
|
192
|
+
styled: renderStyleForSourceOrFallback(
|
|
193
|
+
uiTheme,
|
|
194
|
+
colorSource,
|
|
195
|
+
editorThinkingStyle(config, plain),
|
|
196
|
+
"muted",
|
|
197
|
+
plain,
|
|
198
|
+
),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (name === "session_name") {
|
|
202
|
+
return { plain, styled: safeThemeFg(uiTheme, "border", plain) };
|
|
203
|
+
}
|
|
204
|
+
return { plain: "", styled: "" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderTokens(
|
|
208
|
+
tokens: FormatToken[],
|
|
209
|
+
values: EditorMetadataValues,
|
|
210
|
+
uiTheme: Theme,
|
|
211
|
+
config: PolishedTuiConfig,
|
|
212
|
+
): RenderedTokens {
|
|
213
|
+
let styled = "";
|
|
214
|
+
let hasDynamic = false;
|
|
215
|
+
let hasNonEmptyDynamic = false;
|
|
216
|
+
|
|
217
|
+
for (const token of tokens) {
|
|
218
|
+
if (token.kind === "text") {
|
|
219
|
+
const plain = sanitizeEditorMetadataText(token.value);
|
|
220
|
+
if (plain) styled += safeThemeFg(uiTheme, "border", plain);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (token.kind === "fill") {
|
|
224
|
+
hasDynamic = true;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (token.kind === "var") {
|
|
228
|
+
hasDynamic = true;
|
|
229
|
+
const rendered = renderVariable(token.name, values, uiTheme, config);
|
|
230
|
+
styled += rendered.styled;
|
|
231
|
+
if (rendered.plain) hasNonEmptyDynamic = true;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const rendered = renderTokens(token.tokens, values, uiTheme, config);
|
|
236
|
+
const visible = !rendered.hasDynamic || rendered.hasNonEmptyDynamic;
|
|
237
|
+
hasDynamic = true;
|
|
238
|
+
if (visible) {
|
|
239
|
+
styled += rendered.styled;
|
|
240
|
+
hasNonEmptyDynamic = true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { styled, hasDynamic, hasNonEmptyDynamic };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function renderEditorMetadataFormat(
|
|
248
|
+
format: string,
|
|
249
|
+
values: EditorMetadataValues,
|
|
250
|
+
uiTheme: Theme,
|
|
251
|
+
config: PolishedTuiConfig,
|
|
252
|
+
): string {
|
|
253
|
+
return renderTokens(
|
|
254
|
+
parseFooterFormat(sanitizeEditorMetadataText(format)),
|
|
255
|
+
values,
|
|
256
|
+
uiTheme,
|
|
257
|
+
config,
|
|
258
|
+
).styled;
|
|
259
|
+
}
|
|
@@ -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
|
+
}
|