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 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(state: FooterState, ctx: ExtensionContext, cacheHitIcon: string): void {
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
- state.modelLabel = ctx.model?.id ?? "no-model";
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);
@@ -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(/\[[/?][^\]]+\]/g, "");
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 isRenderedModelMetaLine(line: string, modelMeta: EditorMeta): boolean {
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
- modelMeta: EditorMeta,
174
- previousMeta?: EditorMeta,
175
- ): boolean {
176
- return (
177
- lines.length >= 3 &&
178
- isHorizontalBorder(lines[0] ?? "") &&
179
- isHorizontalBorder(lines.at(-1) ?? "") &&
180
- hasRenderedModelMetaLine(lines.slice(1, -1), modelMeta, previousMeta)
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 removeRenderedModelMetaLines(
190
+ function splitPolishedFrame(
185
191
  lines: string[],
186
- modelMeta: EditorMeta,
187
- previousMeta?: EditorMeta,
188
- ): string[] {
189
- const result: string[] = [];
190
- for (let index = 0; index < lines.length; index++) {
191
- const line = lines[index] ?? "";
192
- if (matchesAnyModelMeta(line, modelMeta, previousMeta)) continue;
193
-
194
- const plain = plainRenderedText(line).trim();
195
- const previousWasMeta =
196
- index > 0 && matchesAnyModelMeta(lines[index - 1] ?? "", modelMeta, previousMeta);
197
- const nextIsMeta =
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
- autocompleteCount > 0 && autocompleteCount < baseRendered.length
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 stalePolishedFrame = isAlreadyPolishedFrame(editorFrame, modelMeta, previousModelMeta);
281
- const editorLines = removeStalePolishedLeadingSpacer(
282
- removeRenderedModelMetaLines(editorFrame.slice(1, -1), modelMeta, previousModelMeta),
283
- stalePolishedFrame,
284
- );
285
- const model = renderStyleForSourceOrFallback(
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
- colorSource,
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
- const result = renderPolishedFrame({
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
- this.previousModelMeta = modelMeta;
514
- return result;
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",