pi-zentui 0.12.0 → 0.13.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 +29 -0
- package/extensions/zentui/config.ts +17 -0
- package/extensions/zentui/editor-metadata-format.ts +259 -0
- package/extensions/zentui/index.ts +7 -1
- 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,10 @@ Default config values — copy this and change any value you want:
|
|
|
157
157
|
{
|
|
158
158
|
"projectRefreshIntervalMs": 30000,
|
|
159
159
|
"footerFormat": "",
|
|
160
|
+
"editorMetadataFormat": "$model $provider( $thinking)",
|
|
160
161
|
"separator": "pipe",
|
|
161
162
|
"contextStyle": "text",
|
|
163
|
+
"editorModelLabel": "id",
|
|
162
164
|
"contextThresholds": {
|
|
163
165
|
"warning": 70,
|
|
164
166
|
"error": 90
|
|
@@ -278,6 +280,8 @@ Default config values — copy this and change any value you want:
|
|
|
278
280
|
- 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
281
|
- `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
282
|
- `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
|
+
- `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
|
+
- `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
285
|
- `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
286
|
- `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
|
|
283
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` **Layout** tab cycles path mode and path depth (`0`–`5`; depth is ignored for basename). Example: `~/Projects/foo/bar` with `depth: 2` → `…/foo/bar`.
|
|
@@ -298,6 +302,31 @@ Default config values — copy this and change any value you want:
|
|
|
298
302
|
|
|
299
303
|
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
304
|
|
|
305
|
+
## Editor Metadata Format
|
|
306
|
+
|
|
307
|
+
Set `editorMetadataFormat` in `~/.pi/agent/zentui.json` to customize the left side of the editor metadata row:
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{
|
|
311
|
+
"editorMetadataFormat": "$model_name ($model_id)( · $provider)( · $thinking)( · $session_name)"
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
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.
|
|
316
|
+
|
|
317
|
+
| Token | Renders |
|
|
318
|
+
| --------------- | --------------------------------------------------------------------------------------------- |
|
|
319
|
+
| `$model` | label selected by `editorModelLabel` (`id`, or name with ID fallback) |
|
|
320
|
+
| `$model_id` | active Pi model ID |
|
|
321
|
+
| `$model_name` | active Pi model display name; empty when no name is set |
|
|
322
|
+
| `$provider` | provider label using Zentui's existing formatting |
|
|
323
|
+
| `$thinking` | current thinking level; empty when thinking is `off` |
|
|
324
|
+
| `$session_name` | current Pi session name; empty when unnamed |
|
|
325
|
+
|
|
326
|
+
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.
|
|
327
|
+
|
|
328
|
+
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.
|
|
329
|
+
|
|
301
330
|
## Footer Format Template
|
|
302
331
|
|
|
303
332
|
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,7 @@ 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";
|
|
35
36
|
|
|
36
37
|
export type ContextThresholds = {
|
|
37
38
|
warning: number;
|
|
@@ -121,12 +122,15 @@ export type ExtensionStatusesConfig = {
|
|
|
121
122
|
|
|
122
123
|
const DEFAULT_PROJECT_REFRESH_INTERVAL_MS = 30_000;
|
|
123
124
|
const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
|
|
125
|
+
export const DEFAULT_EDITOR_METADATA_FORMAT = "$model $provider( $thinking)";
|
|
124
126
|
|
|
125
127
|
export type PolishedTuiConfig = {
|
|
126
128
|
projectRefreshIntervalMs: number;
|
|
127
129
|
footerFormat: string;
|
|
130
|
+
editorMetadataFormat: string;
|
|
128
131
|
separator: SeparatorStyle;
|
|
129
132
|
contextStyle: ContextStyle;
|
|
133
|
+
editorModelLabel: ModelLabelSource;
|
|
130
134
|
contextThresholds: ContextThresholds;
|
|
131
135
|
pathDisplay: PathDisplayConfig;
|
|
132
136
|
gitBranch: GitBranchConfig;
|
|
@@ -221,8 +225,10 @@ export const configPath = join(getAgentDir(), "zentui.json");
|
|
|
221
225
|
export const defaultConfig: PolishedTuiConfig = {
|
|
222
226
|
projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
|
|
223
227
|
footerFormat: "",
|
|
228
|
+
editorMetadataFormat: DEFAULT_EDITOR_METADATA_FORMAT,
|
|
224
229
|
separator: "pipe",
|
|
225
230
|
contextStyle: "text",
|
|
231
|
+
editorModelLabel: "id",
|
|
226
232
|
contextThresholds: { warning: 70, error: 90 },
|
|
227
233
|
pathDisplay: { mode: "basename", depth: 0 },
|
|
228
234
|
gitBranch: { maxLength: "full" },
|
|
@@ -327,6 +333,11 @@ function parseContextStyle(value: unknown): ContextStyle {
|
|
|
327
333
|
return defaultConfig.contextStyle;
|
|
328
334
|
}
|
|
329
335
|
|
|
336
|
+
function parseEditorModelLabel(value: unknown): ModelLabelSource {
|
|
337
|
+
if (value === "id" || value === "name") return value;
|
|
338
|
+
return defaultConfig.editorModelLabel;
|
|
339
|
+
}
|
|
340
|
+
|
|
330
341
|
export function isSeparatorStyle(value: unknown): value is SeparatorStyle {
|
|
331
342
|
return value === "pipe" || value === "dot" || value === "chevron" || value === "none";
|
|
332
343
|
}
|
|
@@ -761,11 +772,17 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
|
761
772
|
const fixedEditor = isRecord(config.fixedEditor)
|
|
762
773
|
? normalizeFixedEditorConfig(config.fixedEditor as Record<string, unknown>)
|
|
763
774
|
: defaultConfig.fixedEditor;
|
|
775
|
+
const editorMetadataFormat = stringValue(config, "editorMetadataFormat");
|
|
764
776
|
return {
|
|
765
777
|
projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
|
|
766
778
|
footerFormat: stringValue(config, "footerFormat") ?? "",
|
|
779
|
+
editorMetadataFormat:
|
|
780
|
+
editorMetadataFormat && editorMetadataFormat.length > 0
|
|
781
|
+
? editorMetadataFormat
|
|
782
|
+
: DEFAULT_EDITOR_METADATA_FORMAT,
|
|
767
783
|
separator: parseSeparatorStyle(config.separator),
|
|
768
784
|
contextStyle: parseContextStyle(config.contextStyle),
|
|
785
|
+
editorModelLabel: parseEditorModelLabel(config.editorModelLabel),
|
|
769
786
|
contextThresholds: parseContextThresholds(config.contextThresholds),
|
|
770
787
|
pathDisplay: parsePathDisplay(config.pathDisplay),
|
|
771
788
|
gitBranch,
|
|
@@ -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
|
+
}
|
|
@@ -120,7 +120,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
120
120
|
const getThinkingLevel = () =>
|
|
121
121
|
sessionLifecycle.isCurrent() ? pi.getThinkingLevel() : ("off" as const);
|
|
122
122
|
const syncFooterState = (ctx: ExtensionContext) =>
|
|
123
|
-
syncState(state, ctx, currentConfig.icons.cacheHit);
|
|
123
|
+
syncState(state, ctx, currentConfig.icons.cacheHit, currentConfig.editorModelLabel);
|
|
124
124
|
|
|
125
125
|
type ProjectRefreshTarget = { cwd: string; generation: number };
|
|
126
126
|
const refreshProjectState = async ({ cwd, generation }: ProjectRefreshTarget) => {
|
|
@@ -244,7 +244,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
244
244
|
getCurrentConfig,
|
|
245
245
|
() => ({
|
|
246
246
|
modelLabel: state.modelLabel,
|
|
247
|
+
modelId: state.modelId,
|
|
248
|
+
modelName: state.modelName,
|
|
247
249
|
providerLabel: state.providerLabel,
|
|
250
|
+
sessionName: ctx.sessionManager.getSessionName() ?? "",
|
|
248
251
|
}),
|
|
249
252
|
getThinkingLevel,
|
|
250
253
|
)) as ZentuiEditorFactory;
|
|
@@ -264,7 +267,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
264
267
|
getCurrentConfig,
|
|
265
268
|
() => ({
|
|
266
269
|
modelLabel: state.modelLabel,
|
|
270
|
+
modelId: state.modelId,
|
|
271
|
+
modelName: state.modelName,
|
|
267
272
|
providerLabel: state.providerLabel,
|
|
273
|
+
sessionName: ctx.sessionManager.getSessionName() ?? "",
|
|
268
274
|
}),
|
|
269
275
|
getThinkingLevel,
|
|
270
276
|
)) as ZentuiEditorFactory;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ModelLabelSource } from "./config";
|
|
2
3
|
import {
|
|
3
4
|
buildContextLabel,
|
|
4
5
|
buildCostLabel,
|
|
@@ -12,6 +13,8 @@ import type { RuntimeInfo } from "./runtime";
|
|
|
12
13
|
|
|
13
14
|
export type FooterState = GitStatusSummary & {
|
|
14
15
|
modelLabel: string;
|
|
16
|
+
modelId: string;
|
|
17
|
+
modelName: string;
|
|
15
18
|
providerLabel: string;
|
|
16
19
|
contextLabel: string;
|
|
17
20
|
tokenLabel: string;
|
|
@@ -24,6 +27,8 @@ export type FooterState = GitStatusSummary & {
|
|
|
24
27
|
export function createInitialState(gitDefaults: GitStatusSummary): FooterState {
|
|
25
28
|
return {
|
|
26
29
|
modelLabel: "no-model",
|
|
30
|
+
modelId: "",
|
|
31
|
+
modelName: "",
|
|
27
32
|
providerLabel: "Unknown",
|
|
28
33
|
contextLabel: "--",
|
|
29
34
|
tokenLabel: "↑0 ↓0",
|
|
@@ -35,9 +40,17 @@ export function createInitialState(gitDefaults: GitStatusSummary): FooterState {
|
|
|
35
40
|
};
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
export function syncState(
|
|
43
|
+
export function syncState(
|
|
44
|
+
state: FooterState,
|
|
45
|
+
ctx: ExtensionContext,
|
|
46
|
+
cacheHitIcon: string,
|
|
47
|
+
modelLabelSource: ModelLabelSource,
|
|
48
|
+
): void {
|
|
39
49
|
const totals = getUsageTotals(ctx);
|
|
40
|
-
|
|
50
|
+
const m = ctx.model;
|
|
51
|
+
state.modelId = m?.id ?? "";
|
|
52
|
+
state.modelName = m?.name ?? "";
|
|
53
|
+
state.modelLabel = (modelLabelSource === "name" ? m?.name || m?.id : m?.id) ?? "no-model";
|
|
41
54
|
state.providerLabel = formatProviderLabel(ctx.model?.provider);
|
|
42
55
|
state.contextLabel = buildContextLabel(ctx);
|
|
43
56
|
state.tokenLabel = buildTokenLabel(totals, cacheHitIcon);
|
package/extensions/zentui/ui.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
visibleWidth,
|
|
10
10
|
} from "@earendil-works/pi-tui";
|
|
11
11
|
import type { PolishedTuiConfig } from "./config";
|
|
12
|
+
import { renderEditorMetadataFormat } from "./editor-metadata-format";
|
|
12
13
|
import {
|
|
13
14
|
EDITOR_ACCENT_FALLBACK,
|
|
14
15
|
EDITOR_BORDER_FALLBACK,
|
|
@@ -16,6 +17,13 @@ import {
|
|
|
16
17
|
safeThemeFg,
|
|
17
18
|
} from "./style";
|
|
18
19
|
|
|
20
|
+
const SPLIT_POLISHED_FRAME: unique symbol = Symbol.for("pi-zentui.polished-frame");
|
|
21
|
+
|
|
22
|
+
type PolishedFrameSplit = {
|
|
23
|
+
editorLines: string[];
|
|
24
|
+
trailingLines: string[];
|
|
25
|
+
};
|
|
26
|
+
|
|
19
27
|
type AutocompleteEditorInternals = {
|
|
20
28
|
autocompleteList?: Pick<Component, "render">;
|
|
21
29
|
isShowingAutocomplete?: () => boolean;
|
|
@@ -42,11 +50,15 @@ type WrappedEditor = EditorComponent &
|
|
|
42
50
|
setAutocompleteProvider?: (provider: AutocompleteProvider) => void;
|
|
43
51
|
setPaddingX?: (padding: number) => void;
|
|
44
52
|
setAutocompleteMaxVisible?: (maxVisible: number) => void;
|
|
53
|
+
[SPLIT_POLISHED_FRAME]?: (lines: string[]) => PolishedFrameSplit | undefined;
|
|
45
54
|
};
|
|
46
55
|
|
|
47
56
|
type EditorMeta = {
|
|
48
57
|
modelLabel: string;
|
|
58
|
+
modelId?: string;
|
|
59
|
+
modelName?: string;
|
|
49
60
|
providerLabel: string;
|
|
61
|
+
sessionName?: string;
|
|
50
62
|
};
|
|
51
63
|
|
|
52
64
|
type PolishedFrameOptions = {
|
|
@@ -56,9 +68,9 @@ type PolishedFrameOptions = {
|
|
|
56
68
|
uiTheme: Theme;
|
|
57
69
|
config: PolishedTuiConfig;
|
|
58
70
|
modelMeta: EditorMeta;
|
|
59
|
-
previousModelMeta?: EditorMeta;
|
|
60
71
|
thinkingLevel: string | undefined;
|
|
61
72
|
rightStatus?: string;
|
|
73
|
+
splitBaseFrame?: (lines: string[]) => PolishedFrameSplit | undefined;
|
|
62
74
|
};
|
|
63
75
|
|
|
64
76
|
function clampRenderedLines(lines: string[], width: number): string[] {
|
|
@@ -72,23 +84,6 @@ function fillLine(content: string, width: number): string {
|
|
|
72
84
|
return `${truncated}${pad}`;
|
|
73
85
|
}
|
|
74
86
|
|
|
75
|
-
function editorThinkingStyle(config: PolishedTuiConfig, level: string): string | undefined {
|
|
76
|
-
switch (level.toLowerCase()) {
|
|
77
|
-
case "minimal":
|
|
78
|
-
return config.colors.editorThinkingMinimal ?? config.colors.editorThinking;
|
|
79
|
-
case "low":
|
|
80
|
-
return config.colors.editorThinkingLow ?? config.colors.editorThinking;
|
|
81
|
-
case "medium":
|
|
82
|
-
return config.colors.editorThinkingMedium ?? config.colors.editorThinking;
|
|
83
|
-
case "high":
|
|
84
|
-
return config.colors.editorThinkingHigh ?? config.colors.editorThinking;
|
|
85
|
-
case "xhigh":
|
|
86
|
-
return config.colors.editorThinkingXhigh ?? config.colors.editorThinking;
|
|
87
|
-
default:
|
|
88
|
-
return config.colors.editorThinking;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
87
|
function copyFriendlyPrompt(config: PolishedTuiConfig, uiTheme: Theme, reset: string): string {
|
|
93
88
|
const promptIcon = config.icons.editorPrompt;
|
|
94
89
|
return promptIcon
|
|
@@ -137,7 +132,7 @@ function plainRenderedText(line: string): string {
|
|
|
137
132
|
return line
|
|
138
133
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
139
134
|
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
|
|
140
|
-
.replace(/\[[
|
|
135
|
+
.replace(/\[\/?[^\]]+\]/g, "");
|
|
141
136
|
}
|
|
142
137
|
|
|
143
138
|
function isHorizontalBorder(line: string): boolean {
|
|
@@ -145,70 +140,67 @@ function isHorizontalBorder(line: string): boolean {
|
|
|
145
140
|
return plain.length > 0 && /^─+$/.test(plain);
|
|
146
141
|
}
|
|
147
142
|
|
|
148
|
-
function
|
|
149
|
-
const plain = plainRenderedText(line);
|
|
150
|
-
return plain.includes(modelMeta.modelLabel) && plain.includes(modelMeta.providerLabel);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function matchesAnyModelMeta(
|
|
154
|
-
line: string,
|
|
155
|
-
modelMeta: EditorMeta,
|
|
156
|
-
previousMeta?: EditorMeta,
|
|
157
|
-
): boolean {
|
|
158
|
-
if (isRenderedModelMetaLine(line, modelMeta)) return true;
|
|
159
|
-
if (previousMeta && isRenderedModelMetaLine(line, previousMeta)) return true;
|
|
160
|
-
return false;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function hasRenderedModelMetaLine(
|
|
164
|
-
lines: string[],
|
|
165
|
-
modelMeta: EditorMeta,
|
|
166
|
-
previousMeta?: EditorMeta,
|
|
167
|
-
): boolean {
|
|
168
|
-
return lines.some((line) => matchesAnyModelMeta(line, modelMeta, previousMeta));
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function isAlreadyPolishedFrame(
|
|
143
|
+
function unwrapPolishedFrameOnly(
|
|
172
144
|
lines: string[],
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
):
|
|
176
|
-
|
|
177
|
-
lines.length
|
|
178
|
-
isHorizontalBorder(lines[0] ?? "")
|
|
179
|
-
isHorizontalBorder(lines.at(-1) ?? "")
|
|
180
|
-
|
|
181
|
-
|
|
145
|
+
config: PolishedTuiConfig,
|
|
146
|
+
uiTheme: Theme,
|
|
147
|
+
): string[] | undefined {
|
|
148
|
+
if (
|
|
149
|
+
lines.length < 5 ||
|
|
150
|
+
!isHorizontalBorder(lines[0] ?? "") ||
|
|
151
|
+
!isHorizontalBorder(lines.at(-1) ?? "")
|
|
152
|
+
)
|
|
153
|
+
return undefined;
|
|
154
|
+
|
|
155
|
+
const interior = lines.slice(1, -1);
|
|
156
|
+
if (interior.length < 3) return undefined;
|
|
157
|
+
|
|
158
|
+
if (config.features.copyFriendly) {
|
|
159
|
+
if (
|
|
160
|
+
plainRenderedText(interior[0] ?? "").trim() !== "" ||
|
|
161
|
+
plainRenderedText(interior.at(-2) ?? "").trim() !== "" ||
|
|
162
|
+
!(interior.at(-1) ?? "").startsWith(" ")
|
|
163
|
+
)
|
|
164
|
+
return undefined;
|
|
165
|
+
|
|
166
|
+
const { prompt, promptWidth } = getEditorChromeWidths(config, uiTheme, "\x1b[0m");
|
|
167
|
+
const continuation = " ".repeat(promptWidth);
|
|
168
|
+
const content = interior.slice(1, -2);
|
|
169
|
+
const unwrapped: string[] = [];
|
|
170
|
+
for (let index = 0; index < content.length; index++) {
|
|
171
|
+
const prefix = index === 0 ? prompt : continuation;
|
|
172
|
+
const line = content[index] ?? "";
|
|
173
|
+
if (prefix && !line.startsWith(prefix)) return undefined;
|
|
174
|
+
unwrapped.push(prefix ? line.slice(prefix.length) : line);
|
|
175
|
+
}
|
|
176
|
+
return unwrapped;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const { rail } = getEditorChromeWidths(config, uiTheme, "\x1b[0m");
|
|
180
|
+
if (!rail || interior.some((line) => !line.startsWith(rail))) return undefined;
|
|
181
|
+
const unrailed = interior.map((line) => line.slice(rail.length));
|
|
182
|
+
if (
|
|
183
|
+
plainRenderedText(unrailed[0] ?? "").trim() !== "" ||
|
|
184
|
+
plainRenderedText(unrailed.at(-2) ?? "").trim() !== ""
|
|
185
|
+
)
|
|
186
|
+
return undefined;
|
|
187
|
+
return unrailed.slice(1, -2);
|
|
182
188
|
}
|
|
183
189
|
|
|
184
|
-
function
|
|
190
|
+
function splitPolishedFrame(
|
|
185
191
|
lines: string[],
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
):
|
|
189
|
-
|
|
190
|
-
for (let
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
index < lines.length - 1 &&
|
|
199
|
-
matchesAnyModelMeta(lines[index + 1] ?? "", modelMeta, previousMeta);
|
|
200
|
-
if (!plain && (previousWasMeta || nextIsMeta)) continue;
|
|
201
|
-
|
|
202
|
-
result.push(line);
|
|
203
|
-
}
|
|
204
|
-
return result;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function removeStalePolishedLeadingSpacer(lines: string[], shouldRemove: boolean): string[] {
|
|
208
|
-
if (!shouldRemove || lines.length === 0) return lines;
|
|
209
|
-
const firstLine = lines[0] ?? "";
|
|
210
|
-
if (plainRenderedText(firstLine).trim()) return lines;
|
|
211
|
-
return lines.slice(1);
|
|
192
|
+
config: PolishedTuiConfig,
|
|
193
|
+
uiTheme: Theme,
|
|
194
|
+
): PolishedFrameSplit | undefined {
|
|
195
|
+
if (!isHorizontalBorder(lines[0] ?? "")) return undefined;
|
|
196
|
+
for (let bottomIndex = lines.length - 1; bottomIndex >= 4; bottomIndex--) {
|
|
197
|
+
if (!isHorizontalBorder(lines[bottomIndex] ?? "")) continue;
|
|
198
|
+
const editorLines = unwrapPolishedFrameOnly(lines.slice(0, bottomIndex + 1), config, uiTheme);
|
|
199
|
+
if (editorLines) {
|
|
200
|
+
return { editorLines, trailingLines: lines.slice(bottomIndex + 1) };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
212
204
|
}
|
|
213
205
|
|
|
214
206
|
function vimModeColor(mode: string): string {
|
|
@@ -244,9 +236,9 @@ function renderPolishedFrame({
|
|
|
244
236
|
uiTheme,
|
|
245
237
|
config,
|
|
246
238
|
modelMeta,
|
|
247
|
-
previousModelMeta,
|
|
248
239
|
thinkingLevel,
|
|
249
240
|
rightStatus,
|
|
241
|
+
splitBaseFrame,
|
|
250
242
|
}: PolishedFrameOptions): string[] {
|
|
251
243
|
if (width <= 2) return clampRenderedLines(baseRendered, width);
|
|
252
244
|
|
|
@@ -262,56 +254,37 @@ function renderPolishedFrame({
|
|
|
262
254
|
|
|
263
255
|
if (baseRendered.length < 2) return clampRenderedLines(baseRendered, width);
|
|
264
256
|
|
|
257
|
+
const ownedFrame = splitBaseFrame?.(baseRendered);
|
|
265
258
|
const { autocompleteList } = autocompleteSource;
|
|
266
259
|
const autocompleteCount =
|
|
267
|
-
isShowingAutocomplete && typeof autocompleteList?.render === "function"
|
|
260
|
+
!ownedFrame && isShowingAutocomplete && typeof autocompleteList?.render === "function"
|
|
268
261
|
? autocompleteList.render(innerWidth).length
|
|
269
262
|
: 0;
|
|
270
263
|
const editorFrame =
|
|
271
|
-
autocompleteCount > 0 && autocompleteCount < baseRendered.length
|
|
264
|
+
!ownedFrame && autocompleteCount > 0 && autocompleteCount < baseRendered.length
|
|
272
265
|
? baseRendered.slice(0, -autocompleteCount)
|
|
273
266
|
: baseRendered;
|
|
274
|
-
const autocompleteLines =
|
|
275
|
-
|
|
267
|
+
const autocompleteLines = ownedFrame
|
|
268
|
+
? ownedFrame.trailingLines
|
|
269
|
+
: autocompleteCount > 0 && autocompleteCount < baseRendered.length
|
|
276
270
|
? baseRendered.slice(-autocompleteCount)
|
|
277
271
|
: [];
|
|
278
272
|
if (editorFrame.length < 2) return clampRenderedLines(baseRendered, width);
|
|
279
273
|
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
274
|
+
const editorLines = ownedFrame?.editorLines ?? editorFrame.slice(1, -1);
|
|
275
|
+
const meta = renderEditorMetadataFormat(
|
|
276
|
+
config.editorMetadataFormat,
|
|
277
|
+
{
|
|
278
|
+
model: modelMeta.modelLabel,
|
|
279
|
+
modelId: modelMeta.modelId ?? "",
|
|
280
|
+
modelName: modelMeta.modelName ?? "",
|
|
281
|
+
provider: modelMeta.providerLabel,
|
|
282
|
+
thinking: thinkingLevel ?? "",
|
|
283
|
+
sessionName: modelMeta.sessionName ?? "",
|
|
284
|
+
},
|
|
286
285
|
uiTheme,
|
|
287
|
-
|
|
288
|
-
config.colors.editorModel,
|
|
289
|
-
EDITOR_ACCENT_FALLBACK,
|
|
290
|
-
modelMeta.modelLabel,
|
|
291
|
-
);
|
|
292
|
-
const provider = renderStyleForSourceOrFallback(
|
|
293
|
-
uiTheme,
|
|
294
|
-
colorSource,
|
|
295
|
-
config.colors.editorProvider,
|
|
296
|
-
"text",
|
|
297
|
-
modelMeta.providerLabel,
|
|
286
|
+
config,
|
|
298
287
|
);
|
|
299
|
-
const renderedModelMeta = [model, provider]
|
|
300
|
-
.filter(Boolean)
|
|
301
|
-
.join(safeThemeFg(uiTheme, "borderMuted", " "));
|
|
302
|
-
const metaParts = [renderedModelMeta];
|
|
303
|
-
if (thinkingLevel && thinkingLevel !== "off") {
|
|
304
|
-
metaParts.push(
|
|
305
|
-
renderStyleForSourceOrFallback(
|
|
306
|
-
uiTheme,
|
|
307
|
-
colorSource,
|
|
308
|
-
editorThinkingStyle(config, thinkingLevel),
|
|
309
|
-
"muted",
|
|
310
|
-
thinkingLevel,
|
|
311
|
-
),
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
const meta = metaParts.filter(Boolean).join(safeThemeFg(uiTheme, "border", " "));
|
|
315
288
|
const copyFriendlyMeta = composeMetadataLine(meta, rightStatus, Math.max(0, width - 1));
|
|
316
289
|
const railedMeta = composeMetadataLine(meta, rightStatus, innerWidth);
|
|
317
290
|
|
|
@@ -358,7 +331,6 @@ export class PolishedEditor extends CustomEditor {
|
|
|
358
331
|
private readonly getThinkingLevel: () => string | undefined;
|
|
359
332
|
private readonly getConfig: () => PolishedTuiConfig;
|
|
360
333
|
private readonly uiTheme: Theme;
|
|
361
|
-
private previousModelMeta?: EditorMeta;
|
|
362
334
|
|
|
363
335
|
constructor(
|
|
364
336
|
tui: TUI,
|
|
@@ -394,17 +366,17 @@ export class PolishedEditor extends CustomEditor {
|
|
|
394
366
|
uiTheme: this.uiTheme,
|
|
395
367
|
config,
|
|
396
368
|
modelMeta,
|
|
397
|
-
previousModelMeta: this.previousModelMeta,
|
|
398
369
|
thinkingLevel: this.getThinkingLevel(),
|
|
399
370
|
});
|
|
400
|
-
this.previousModelMeta = modelMeta;
|
|
401
371
|
return result;
|
|
402
372
|
}
|
|
373
|
+
|
|
374
|
+
[SPLIT_POLISHED_FRAME](lines: string[]): PolishedFrameSplit | undefined {
|
|
375
|
+
return splitPolishedFrame(lines, this.getConfig(), this.uiTheme);
|
|
376
|
+
}
|
|
403
377
|
}
|
|
404
378
|
|
|
405
379
|
export class WrappedPolishedEditor implements EditorComponent {
|
|
406
|
-
private previousModelMeta?: EditorMeta;
|
|
407
|
-
|
|
408
380
|
constructor(
|
|
409
381
|
private readonly base: WrappedEditor,
|
|
410
382
|
private readonly uiTheme: Theme,
|
|
@@ -499,19 +471,21 @@ export class WrappedPolishedEditor implements EditorComponent {
|
|
|
499
471
|
const rendered = this.base.render(innerWidth);
|
|
500
472
|
const vimStatus = readVimStatus(this.base, this.uiTheme);
|
|
501
473
|
const modelMeta = this.getModelMeta();
|
|
502
|
-
|
|
474
|
+
return renderPolishedFrame({
|
|
503
475
|
width,
|
|
504
476
|
baseRendered: rendered,
|
|
505
477
|
autocompleteSource: this.base,
|
|
506
478
|
uiTheme: this.uiTheme,
|
|
507
479
|
config,
|
|
508
480
|
modelMeta,
|
|
509
|
-
previousModelMeta: this.previousModelMeta,
|
|
510
481
|
thinkingLevel: this.getThinkingLevel(),
|
|
511
482
|
rightStatus: vimStatus,
|
|
483
|
+
splitBaseFrame: this.base[SPLIT_POLISHED_FRAME]?.bind(this.base),
|
|
512
484
|
});
|
|
513
|
-
|
|
514
|
-
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
[SPLIT_POLISHED_FRAME](lines: string[]): PolishedFrameSplit | undefined {
|
|
488
|
+
return splitPolishedFrame(lines, this.getConfig(), this.uiTheme);
|
|
515
489
|
}
|
|
516
490
|
|
|
517
491
|
invalidate(): void {
|