pi-zentui 0.24.0 → 0.25.1
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 +4 -2
- package/docs/configuration.md +10 -3
- package/extensions/zentui/accent-rail-editor.ts +2 -2
- package/extensions/zentui/config.ts +11 -3
- package/extensions/zentui/footer.ts +8 -3
- package/extensions/zentui/format.ts +2 -1
- package/extensions/zentui/icons.ts +53 -13
- package/extensions/zentui/index.ts +4 -2
- package/extensions/zentui/minimalist-editor.ts +16 -1
- package/extensions/zentui/settings-command.ts +13 -4
- package/extensions/zentui/settings-previews.ts +1 -0
- package/extensions/zentui/working-line.ts +141 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,7 +114,7 @@ The displayed preset is derived from your current selections: individual changes
|
|
|
114
114
|
|
|
115
115
|
### Minimal overrides
|
|
116
116
|
|
|
117
|
-
Installation enables Opencode Editor, Framed User messages, Zentui selector borders, and Starship Footer. Working line and Thinking (Experimental) remain disabled. Missing fields retain those defaults; there is no automatic migration. Auto icons
|
|
117
|
+
Installation enables Opencode Editor, Framed User messages, Zentui selector borders, and Starship Footer. Working line and Thinking (Experimental) remain disabled. Missing fields retain those defaults; there is no automatic migration. Auto icons use Nerd glyphs for iTerm2, WezTerm, Ghostty, Kitty, and Alacritty environment signals, and otherwise fall back to ASCII. Set `ZENTUI_NERD_FONTS=1` or `0` to override Auto. These environment heuristics cannot detect whether a Nerd Font is installed or configured; explicit `nerd` and `ascii` modes always win. ASCII changes icons, not the entire UI.
|
|
118
118
|
|
|
119
119
|
Change only what you need. For example, this changes only the Editor's accent:
|
|
120
120
|
|
|
@@ -172,10 +172,12 @@ Pi 0.84 also provides a native fullscreen TUI with a sticky editor and Footer. P
|
|
|
172
172
|
|
|
173
173
|
**Codex quota (opt-in):** Show remaining 5-hour/weekly quota for `openai-codex` through independent Editor and Starship Footer settings, both off by default. See [configuration and private-endpoint limitations](./docs/configuration.md#codex-account-quota), including `$codex_quota` for custom templates.
|
|
174
174
|
|
|
175
|
+
Minimalist can also show the latest assistant prompt's cache hit rate in its top-right metadata. Enable **Editor → Minimalist → Cache hit rate** or set `components.editor.styles.minimalist.showCacheHit` to `true`; it is off by default and does not depend on Footer.
|
|
176
|
+
|
|
175
177
|
## Requirements
|
|
176
178
|
|
|
177
179
|
- [Pi](https://pi.dev) coding agent 0.80.5 or newer
|
|
178
|
-
- A [Nerd Font](https://www.nerdfonts.com/) for icons
|
|
180
|
+
- A [Nerd Font](https://www.nerdfonts.com/) for Nerd icons; Auto falls back to ASCII when no supported terminal signal is present
|
|
179
181
|
|
|
180
182
|
## Development
|
|
181
183
|
|
package/docs/configuration.md
CHANGED
|
@@ -171,6 +171,7 @@ Reference only—not a starter file. Prefer the minimal overrides above. Optiona
|
|
|
171
171
|
"showSessionName": true,
|
|
172
172
|
"showTimer": true,
|
|
173
173
|
"showCost": true,
|
|
174
|
+
"showCacheHit": false,
|
|
174
175
|
"showGit": true,
|
|
175
176
|
"contextThresholds": {
|
|
176
177
|
"warning": 70,
|
|
@@ -475,9 +476,9 @@ Set `ZENTUI_DEBUG=1` when launching Pi to log the workaround diagnostic without
|
|
|
475
476
|
|
|
476
477
|
### Minimalist
|
|
477
478
|
|
|
478
|
-
Set `components.editor.style` to `minimalist` or select it in `/zentui`. The rounded frame places viewport counts, Bash state, current/completed turn duration, and explicit session name at top left; cost, model, thinking, and
|
|
479
|
+
Set `components.editor.style` to `minimalist` or select it in `/zentui`. The rounded frame places viewport counts, Bash state, current/completed turn duration, and explicit session name at top left; cost, model, thinking, context, and optional latest-prompt cache hit rate at top right; viewport count plus Git at bottom left; and configured path at bottom right. Unnamed sessions add no placeholder.
|
|
479
480
|
|
|
480
|
-
Path examples are `src` (`compact`), `zentui/src` (`project`), and `~/Projects/zentui/src` (`full`). Context can render as `11%`, `11%/372k`, or, with the gauge enabled and enough room, `[█░░░░] 11%/372k`. The gauge shortens or disappears before the context text at narrow widths. Session name, timer, cost, and Git can be hidden independently; model, thinking, and context remain structurally stable.
|
|
481
|
+
Path examples are `src` (`compact`), `zentui/src` (`project`), and `~/Projects/zentui/src` (`full`). Context can render as `11%`, `11%/372k`, or, with the gauge enabled and enough room, `[█░░░░] 11%/372k`. Enable `showCacheHit` to append values such as `Cache 98.2%`; it defaults to `false`, omits missing data, yields before context at narrow widths, and remains independent of Footer. The gauge shortens or disappears before the context text at narrow widths. Session name, timer, cost, cache hit rate, and Git can be hidden independently; model, thinking, and context remain structurally stable.
|
|
481
482
|
|
|
482
483
|
Autocomplete stays inside the frame when Pi output can be split safely. Unknown third-party layouts fail open. Footer visibility remains independently controlled by `components.footer.style`; Minimalist does not remove Pi's header.
|
|
483
484
|
|
|
@@ -637,9 +638,15 @@ Zentui accepts at most 16 unique keys, keys up to 64 code units, and values up t
|
|
|
637
638
|
| `↓` | Behind |
|
|
638
639
|
| `⇕` | Diverged |
|
|
639
640
|
|
|
641
|
+
## Icon Auto detection
|
|
642
|
+
|
|
643
|
+
`icons.mode: "auto"` preserves Auto in memory and on disk while deriving an effective mode for the current process. Exact `ZENTUI_NERD_FONTS=1` or `0` overrides Auto. Otherwise Auto selects Nerd glyphs when `TERM_PROGRAM` is `iTerm.app`, `WezTerm`, or `ghostty` (case-insensitive), or when `KITTY_WINDOW_ID` or `ALACRITTY_SOCKET` is nonempty. Unknown terminals, VS Code, and Windows Terminal default to ASCII-safe glyphs. Explicit `nerd` and `ascii` modes ignore the override and environment signals.
|
|
644
|
+
|
|
645
|
+
This is conservative terminal-environment detection, not font probing: terminal identity cannot prove that a Nerd Font is installed or configured. Use explicit mode or `ZENTUI_NERD_FONTS` when Auto chooses incorrectly. Custom icon overrides still win over either effective mode.
|
|
646
|
+
|
|
640
647
|
## Runtime detection
|
|
641
648
|
|
|
642
|
-
Runtime/language modules use Starship Nerd Font symbols and defaults such as `bold green` for Node.js. Theme mode maps those styles through Pi; Footer terminal mode uses the terminal colorscheme's ANSI colors.
|
|
649
|
+
Runtime/language modules use Starship Nerd Font symbols and defaults such as `bold green` for Node.js. Theme mode maps those styles through Pi; Footer terminal mode uses the terminal colorscheme's ANSI colors. In Auto mode, runtime, OS, package, rail, and gauge symbols all use the same derived effective icon mode.
|
|
643
650
|
|
|
644
651
|
| Runtime/language | Detection examples |
|
|
645
652
|
| --- | --- |
|
|
@@ -41,8 +41,8 @@ function clampLines(lines: string[], width: number): string[] {
|
|
|
41
41
|
|
|
42
42
|
function selectedRail(config: ZentuiConfig): string {
|
|
43
43
|
const style = config.components.editor.styles["accent-rail"];
|
|
44
|
-
const fallback = config.icons.
|
|
45
|
-
const configured = config.icons.
|
|
44
|
+
const fallback = config.icons.effectiveMode === "ascii" ? "|" : "▎";
|
|
45
|
+
const configured = config.icons.effectiveMode === "ascii" ? style.asciiRail : style.rail;
|
|
46
46
|
const sanitized = sanitizeEditorMetadataText(configured);
|
|
47
47
|
const glyph = truncateToWidth(sanitized, 1, "");
|
|
48
48
|
return visibleWidth(glyph) === 1 ? glyph : fallback;
|
|
@@ -24,9 +24,9 @@ import {
|
|
|
24
24
|
} from "./component-colors";
|
|
25
25
|
import {
|
|
26
26
|
ICON_GLYPH_KEYS,
|
|
27
|
+
type IconEnvironment,
|
|
27
28
|
type IconGlyphs,
|
|
28
29
|
type IconMode,
|
|
29
|
-
NERD_DEFAULT_ICONS,
|
|
30
30
|
normalizeIconMode,
|
|
31
31
|
type ResolvedIcons,
|
|
32
32
|
resolveConfiguredIcons,
|
|
@@ -139,6 +139,7 @@ export type MinimalistEditorStyleConfig = {
|
|
|
139
139
|
showSessionName: boolean;
|
|
140
140
|
showTimer: boolean;
|
|
141
141
|
showCost: boolean;
|
|
142
|
+
showCacheHit: boolean;
|
|
142
143
|
showGit: boolean;
|
|
143
144
|
contextThresholds: ContextThresholds;
|
|
144
145
|
};
|
|
@@ -477,6 +478,7 @@ const defaultMinimalistStyle: MinimalistEditorStyleConfig = {
|
|
|
477
478
|
showSessionName: true,
|
|
478
479
|
showTimer: true,
|
|
479
480
|
showCost: true,
|
|
481
|
+
showCacheHit: false,
|
|
480
482
|
showGit: true,
|
|
481
483
|
contextThresholds: { warning: 70, error: 90 },
|
|
482
484
|
};
|
|
@@ -550,7 +552,7 @@ const defaultComponents: ComponentsConfig = {
|
|
|
550
552
|
|
|
551
553
|
export const defaultConfig: PolishedTuiConfig = {
|
|
552
554
|
projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
|
|
553
|
-
icons:
|
|
555
|
+
icons: resolveConfiguredIcons("auto"),
|
|
554
556
|
colors: {
|
|
555
557
|
cwd: "bold cyan",
|
|
556
558
|
sessionName: "bold green",
|
|
@@ -1312,6 +1314,7 @@ function resolveComponents(config: ConfigRecord): ComponentsConfig {
|
|
|
1312
1314
|
),
|
|
1313
1315
|
showTimer: parseBoolean(minimalist.showTimer, defaultMinimalistStyle.showTimer),
|
|
1314
1316
|
showCost: parseBoolean(minimalist.showCost, defaultMinimalistStyle.showCost),
|
|
1317
|
+
showCacheHit: parseBoolean(minimalist.showCacheHit, defaultMinimalistStyle.showCacheHit),
|
|
1315
1318
|
showGit: parseBoolean(minimalist.showGit, defaultMinimalistStyle.showGit),
|
|
1316
1319
|
contextThresholds: minimalistThresholds,
|
|
1317
1320
|
},
|
|
@@ -1529,7 +1532,10 @@ export function hasUnsupportedComponentStyle(
|
|
|
1529
1532
|
return unsupportedComponentStyles.get(config)?.has(owner) ?? false;
|
|
1530
1533
|
}
|
|
1531
1534
|
|
|
1532
|
-
export function mergeConfig(
|
|
1535
|
+
export function mergeConfig(
|
|
1536
|
+
parsed: unknown,
|
|
1537
|
+
iconEnvironment: IconEnvironment = process.env,
|
|
1538
|
+
): PolishedTuiConfig {
|
|
1533
1539
|
const config = isRecord(parsed) ? parsed : {};
|
|
1534
1540
|
const iconsRecord = recordValue(config.icons);
|
|
1535
1541
|
const colorsRecord = recordValue(config.colors);
|
|
@@ -1539,6 +1545,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
|
1539
1545
|
icons: resolveConfiguredIcons(
|
|
1540
1546
|
normalizeIconMode(iconsRecord.mode),
|
|
1541
1547
|
normalizeIconOverrides(iconsRecord),
|
|
1548
|
+
iconEnvironment,
|
|
1542
1549
|
),
|
|
1543
1550
|
colors: {
|
|
1544
1551
|
...defaultConfig.colors,
|
|
@@ -1806,6 +1813,7 @@ function applyMinimalistStylePatch(
|
|
|
1806
1813
|
if (patch.showSessionName !== undefined) style.showSessionName = patch.showSessionName;
|
|
1807
1814
|
if (patch.showTimer !== undefined) style.showTimer = patch.showTimer;
|
|
1808
1815
|
if (patch.showCost !== undefined) style.showCost = patch.showCost;
|
|
1816
|
+
if (patch.showCacheHit !== undefined) style.showCacheHit = patch.showCacheHit;
|
|
1809
1817
|
if (patch.showGit !== undefined) style.showGit = patch.showGit;
|
|
1810
1818
|
if (patch.contextThresholds !== undefined) {
|
|
1811
1819
|
style.contextThresholds = { ...style.contextThresholds, ...patch.contextThresholds };
|
|
@@ -240,7 +240,7 @@ export function installFooter(
|
|
|
240
240
|
FOOTER_FORMAT_ALIASES,
|
|
241
241
|
);
|
|
242
242
|
const colorSource = config.components.footer.colorSource;
|
|
243
|
-
const iconMode = config.icons.
|
|
243
|
+
const iconMode = config.icons.effectiveMode;
|
|
244
244
|
const pathDisplay = config.components.footer.styles.starship.pathDisplay;
|
|
245
245
|
const formattedCwd = sanitizeEditorMetadataText(
|
|
246
246
|
formatCwdLabel(ctx.cwd, config.icons.cwd, {
|
|
@@ -451,7 +451,12 @@ export function installFooter(
|
|
|
451
451
|
theme,
|
|
452
452
|
colorSource,
|
|
453
453
|
componentColor(config, "footer", "os"),
|
|
454
|
-
formatOsLabel(
|
|
454
|
+
formatOsLabel(
|
|
455
|
+
config.icons.os,
|
|
456
|
+
iconMode,
|
|
457
|
+
process.platform,
|
|
458
|
+
config.icons.osOverridden,
|
|
459
|
+
),
|
|
455
460
|
);
|
|
456
461
|
case "time":
|
|
457
462
|
return renderStyleForSource(
|
|
@@ -672,7 +677,7 @@ export function installFooter(
|
|
|
672
677
|
theme,
|
|
673
678
|
colorSource,
|
|
674
679
|
componentColor(config, "footer", "os"),
|
|
675
|
-
formatOsLabel(config.icons.os, iconMode),
|
|
680
|
+
formatOsLabel(config.icons.os, iconMode, process.platform, config.icons.osOverridden),
|
|
676
681
|
)
|
|
677
682
|
: "";
|
|
678
683
|
const left = [
|
|
@@ -572,6 +572,7 @@ export function formatOsLabel(
|
|
|
572
572
|
configuredIcon: string,
|
|
573
573
|
mode: IconMode = "auto",
|
|
574
574
|
platform: string = process.platform,
|
|
575
|
+
osOverridden = false,
|
|
575
576
|
): string {
|
|
576
|
-
return resolveOsIcon(configuredIcon, mode, platform);
|
|
577
|
+
return resolveOsIcon(configuredIcon, mode, platform, process.env, osOverridden);
|
|
577
578
|
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
export type IconMode = "auto" | "nerd" | "ascii";
|
|
10
|
+
export type EffectiveIconMode = Exclude<IconMode, "auto">;
|
|
11
|
+
export type IconEnvironment = Readonly<Record<string, string | undefined>>;
|
|
10
12
|
|
|
11
13
|
export type IconGlyphs = {
|
|
12
14
|
cwd: string;
|
|
@@ -31,7 +33,14 @@ export type IconGlyphs = {
|
|
|
31
33
|
package: string;
|
|
32
34
|
};
|
|
33
35
|
|
|
34
|
-
export type ResolvedIcons = IconGlyphs & {
|
|
36
|
+
export type ResolvedIcons = IconGlyphs & {
|
|
37
|
+
/** Canonical user choice; Auto is never replaced with an environment-specific value. */
|
|
38
|
+
mode: IconMode;
|
|
39
|
+
/** Runtime-only mode derived from the canonical choice and current environment. */
|
|
40
|
+
effectiveMode: EffectiveIconMode;
|
|
41
|
+
/** Runtime-only provenance needed when an override equals the active mode default. */
|
|
42
|
+
osOverridden: boolean;
|
|
43
|
+
};
|
|
35
44
|
|
|
36
45
|
export const ICON_GLYPH_KEYS = [
|
|
37
46
|
"cwd",
|
|
@@ -192,39 +201,65 @@ export function normalizeIconMode(value: unknown): IconMode {
|
|
|
192
201
|
return isIconMode(value) ? value : "auto";
|
|
193
202
|
}
|
|
194
203
|
|
|
195
|
-
export function
|
|
196
|
-
|
|
204
|
+
export function detectAutoIconMode(env: IconEnvironment): EffectiveIconMode {
|
|
205
|
+
if (env.ZENTUI_NERD_FONTS === "1") return "nerd";
|
|
206
|
+
if (env.ZENTUI_NERD_FONTS === "0") return "ascii";
|
|
207
|
+
|
|
208
|
+
const termProgram = env.TERM_PROGRAM?.toLowerCase();
|
|
209
|
+
if (termProgram === "iterm.app" || termProgram === "wezterm" || termProgram === "ghostty") {
|
|
210
|
+
return "nerd";
|
|
211
|
+
}
|
|
212
|
+
if (env.KITTY_WINDOW_ID?.trim() || env.ALACRITTY_SOCKET?.trim()) return "nerd";
|
|
213
|
+
return "ascii";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function resolveEffectiveIconMode(
|
|
217
|
+
mode: IconMode,
|
|
218
|
+
env: IconEnvironment = process.env,
|
|
219
|
+
): EffectiveIconMode {
|
|
220
|
+
return mode === "auto" ? detectAutoIconMode(env) : mode;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function modeDefaultIcons(mode: IconMode, env: IconEnvironment = process.env): IconGlyphs {
|
|
224
|
+
return resolveEffectiveIconMode(mode, env) === "ascii"
|
|
225
|
+
? { ...ASCII_DEFAULT_ICONS }
|
|
226
|
+
: { ...NERD_DEFAULT_ICONS };
|
|
197
227
|
}
|
|
198
228
|
|
|
199
229
|
export function resolveConfiguredIcons(
|
|
200
230
|
mode: IconMode,
|
|
201
231
|
overrides: Partial<IconGlyphs> = {},
|
|
232
|
+
env: IconEnvironment = process.env,
|
|
202
233
|
): ResolvedIcons {
|
|
203
|
-
const
|
|
234
|
+
const effectiveMode = resolveEffectiveIconMode(mode, env);
|
|
235
|
+
const base = modeDefaultIcons(effectiveMode);
|
|
204
236
|
const rail =
|
|
205
237
|
typeof overrides.rail === "string" && overrides.rail.trim().length > 0
|
|
206
238
|
? overrides.rail
|
|
207
239
|
: base.rail;
|
|
208
240
|
return {
|
|
209
241
|
mode,
|
|
242
|
+
effectiveMode,
|
|
210
243
|
...base,
|
|
211
244
|
...overrides,
|
|
212
245
|
rail,
|
|
246
|
+
osOverridden: typeof overrides.os === "string",
|
|
213
247
|
};
|
|
214
248
|
}
|
|
215
249
|
|
|
216
|
-
/**
|
|
217
|
-
* Honor a custom `icons.os` when it differs from the mode default.
|
|
218
|
-
* Otherwise map by platform for the active mode.
|
|
219
|
-
*/
|
|
250
|
+
/** Map the active mode default by platform while preserving explicit `icons.os` overrides. */
|
|
220
251
|
export function resolveOsIcon(
|
|
221
252
|
configuredOsIcon: string,
|
|
222
253
|
mode: IconMode = "auto",
|
|
223
254
|
platform: string = process.platform,
|
|
255
|
+
env: IconEnvironment = process.env,
|
|
256
|
+
osOverridden = false,
|
|
224
257
|
): string {
|
|
225
|
-
|
|
258
|
+
if (osOverridden) return configuredOsIcon;
|
|
259
|
+
const effectiveMode = resolveEffectiveIconMode(mode, env);
|
|
260
|
+
const modeDefault = modeDefaultIcons(effectiveMode).os;
|
|
226
261
|
if (configuredOsIcon !== modeDefault) return configuredOsIcon;
|
|
227
|
-
const platformMap =
|
|
262
|
+
const platformMap = effectiveMode === "ascii" ? OS_PLATFORM_ICONS_ASCII : OS_PLATFORM_ICONS_NERD;
|
|
228
263
|
return platformMap[platform] ?? configuredOsIcon;
|
|
229
264
|
}
|
|
230
265
|
|
|
@@ -232,8 +267,9 @@ export function resolveRuntimeSymbol(
|
|
|
232
267
|
name: string,
|
|
233
268
|
nerdSymbol: string,
|
|
234
269
|
mode: IconMode = "auto",
|
|
270
|
+
env: IconEnvironment = process.env,
|
|
235
271
|
): string {
|
|
236
|
-
if (mode !== "ascii") return nerdSymbol;
|
|
272
|
+
if (resolveEffectiveIconMode(mode, env) !== "ascii") return nerdSymbol;
|
|
237
273
|
return RUNTIME_ASCII_SYMBOLS[name] ?? (name.slice(0, 3) || "*");
|
|
238
274
|
}
|
|
239
275
|
|
|
@@ -243,8 +279,12 @@ export function resolveRuntimeSymbol(
|
|
|
243
279
|
* Honors a configured `icons.package` override; otherwise falls back to the
|
|
244
280
|
* mode default (Nerd Font preset / ASCII label).
|
|
245
281
|
*/
|
|
246
|
-
export function resolvePackageIcon(
|
|
247
|
-
|
|
282
|
+
export function resolvePackageIcon(
|
|
283
|
+
configuredPackageIcon: string,
|
|
284
|
+
mode: IconMode = "auto",
|
|
285
|
+
env: IconEnvironment = process.env,
|
|
286
|
+
): string {
|
|
287
|
+
const modeDefault = modeDefaultIcons(mode, env).package;
|
|
248
288
|
if (typeof configuredPackageIcon === "string" && configuredPackageIcon.length > 0) {
|
|
249
289
|
return configuredPackageIcon;
|
|
250
290
|
}
|
|
@@ -796,6 +796,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
796
796
|
thinkingLevel: getThinkingLevel(),
|
|
797
797
|
contextPercent: getContextPercent(ctx),
|
|
798
798
|
contextWindow: getContextWindow(ctx),
|
|
799
|
+
cacheHitRate: state.usageTotals.latestCacheHitRate,
|
|
799
800
|
sessionName: ctx.sessionManager.getSessionName() ?? "",
|
|
800
801
|
agentDurationMs: getAgentDurationMs(),
|
|
801
802
|
agentActive: agentRunActive,
|
|
@@ -836,6 +837,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
836
837
|
thinkingLevel: getThinkingLevel(),
|
|
837
838
|
contextPercent: getContextPercent(ctx),
|
|
838
839
|
contextWindow: getContextWindow(ctx),
|
|
840
|
+
cacheHitRate: state.usageTotals.latestCacheHitRate,
|
|
839
841
|
sessionName: ctx.sessionManager.getSessionName() ?? "",
|
|
840
842
|
agentDurationMs: getAgentDurationMs(),
|
|
841
843
|
agentActive: agentRunActive,
|
|
@@ -1500,7 +1502,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1500
1502
|
interactionMetrics.agentEnd();
|
|
1501
1503
|
pauseAgentRun();
|
|
1502
1504
|
workingLine.finishAgent(ctx);
|
|
1503
|
-
workingLine.
|
|
1505
|
+
workingLine.flushMetrics(displayTokens, interactionMetrics.currentThought(), ctx);
|
|
1504
1506
|
// Reconcile once more after Pi has persisted the assistant message.
|
|
1505
1507
|
syncInteractiveAndProjectStateWithUsage(event, ctx);
|
|
1506
1508
|
});
|
|
@@ -1525,7 +1527,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1525
1527
|
thinkingExperimental.endMessage(event);
|
|
1526
1528
|
const result = interactionMetrics.messageEnd(event.message);
|
|
1527
1529
|
if (result.status === "accepted") {
|
|
1528
|
-
workingLine.
|
|
1530
|
+
workingLine.flushMetrics(result.displayTokens, interactionMetrics.currentThought(), ctx);
|
|
1529
1531
|
}
|
|
1530
1532
|
// Pi notifies extensions before persisting a successful message, so retain its live
|
|
1531
1533
|
// context until agent_end; accepted failed messages clear immediately instead of showing
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
bashModeLabel,
|
|
11
11
|
buildContextGauge,
|
|
12
12
|
contextColorTier,
|
|
13
|
+
formatCacheHitRate,
|
|
13
14
|
formatCount,
|
|
14
15
|
formatCwdLabel,
|
|
15
16
|
formatElapsedDuration,
|
|
@@ -53,6 +54,7 @@ export type MinimalistEditorMetadata = {
|
|
|
53
54
|
thinkingLevel?: string;
|
|
54
55
|
contextPercent?: number;
|
|
55
56
|
contextWindow?: number;
|
|
57
|
+
cacheHitRate?: number;
|
|
56
58
|
sessionName?: string;
|
|
57
59
|
agentDurationMs?: number;
|
|
58
60
|
agentActive?: boolean;
|
|
@@ -256,7 +258,11 @@ function renderTopRight(
|
|
|
256
258
|
let context = renderStyleForSource(uiTheme, source, style, text);
|
|
257
259
|
if (config.components.editor.styles.minimalist.contextGauge) {
|
|
258
260
|
for (const gaugeWidth of [5, 3]) {
|
|
259
|
-
const gauge = `[${buildContextGauge(
|
|
261
|
+
const gauge = `[${buildContextGauge(
|
|
262
|
+
percent,
|
|
263
|
+
gaugeWidth,
|
|
264
|
+
config.icons.effectiveMode === "ascii",
|
|
265
|
+
)}] ${text}`;
|
|
260
266
|
const styledGauge = renderStyleForSource(uiTheme, source, style, gauge);
|
|
261
267
|
const candidate = joinParts([...parts, styledGauge]);
|
|
262
268
|
if (visibleWidth(candidate) <= availableWidth) {
|
|
@@ -277,6 +283,15 @@ function renderTopRight(
|
|
|
277
283
|
}
|
|
278
284
|
parts.push(context);
|
|
279
285
|
}
|
|
286
|
+
const cacheHit =
|
|
287
|
+
config.components.editor.styles.minimalist.showCacheHit &&
|
|
288
|
+
metadata.cacheHitRate !== undefined &&
|
|
289
|
+
Number.isFinite(metadata.cacheHitRate)
|
|
290
|
+
? safeThemeFg(uiTheme, "muted", `Cache ${formatCacheHitRate(metadata.cacheHitRate)}`)
|
|
291
|
+
: "";
|
|
292
|
+
if (cacheHit && (!fit || visibleWidth(joinParts([...parts, cacheHit])) <= availableWidth)) {
|
|
293
|
+
parts.push(cacheHit);
|
|
294
|
+
}
|
|
280
295
|
const quota = renderCodexQuota(metadata.codexQuota, uiTheme, config, "editor");
|
|
281
296
|
if (quota && visibleWidth(joinParts([...parts, quota])) <= availableWidth) parts.push(quota);
|
|
282
297
|
const joined = joinParts(parts);
|
|
@@ -570,7 +570,7 @@ function buildAppearanceItems(config: PolishedTuiConfig): SettingItem[] {
|
|
|
570
570
|
id: "iconMode",
|
|
571
571
|
label: "Icon mode",
|
|
572
572
|
description:
|
|
573
|
-
"Auto
|
|
573
|
+
"Auto uses conservative signals: TERM_PROGRAM=iTerm.app|WezTerm|ghostty (case-insensitive), nonempty KITTY_WINDOW_ID or ALACRITTY_SOCKET, and exact ZENTUI_NERD_FONTS=1|0; otherwise ASCII. Environment heuristics cannot detect whether a Nerd Font is installed/configured.",
|
|
574
574
|
currentValue: config.icons.mode,
|
|
575
575
|
values: iconModeValues,
|
|
576
576
|
},
|
|
@@ -711,6 +711,13 @@ function buildMinimalistEditorStyleItems(config: PolishedTuiConfig): SettingItem
|
|
|
711
711
|
currentValue: featureValue(minimalist.showCost),
|
|
712
712
|
values: featureStateValues,
|
|
713
713
|
},
|
|
714
|
+
{
|
|
715
|
+
id: "minimalistShowCacheHit",
|
|
716
|
+
label: "Cache hit rate",
|
|
717
|
+
description: "Show the latest prompt cache hit rate.",
|
|
718
|
+
currentValue: featureValue(minimalist.showCacheHit),
|
|
719
|
+
values: featureStateValues,
|
|
720
|
+
},
|
|
714
721
|
{
|
|
715
722
|
id: "minimalistShowGit",
|
|
716
723
|
label: "Git",
|
|
@@ -1723,9 +1730,11 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
|
|
|
1723
1730
|
? "showTimer"
|
|
1724
1731
|
: id === "minimalistShowCost"
|
|
1725
1732
|
? "showCost"
|
|
1726
|
-
: id === "
|
|
1727
|
-
? "
|
|
1728
|
-
:
|
|
1733
|
+
: id === "minimalistShowCacheHit"
|
|
1734
|
+
? "showCacheHit"
|
|
1735
|
+
: id === "minimalistShowGit"
|
|
1736
|
+
? "showGit"
|
|
1737
|
+
: undefined;
|
|
1729
1738
|
if (!key) return;
|
|
1730
1739
|
deps.setMinimalist({ [key]: enabled }, ctx);
|
|
1731
1740
|
} else return;
|
|
@@ -40,6 +40,8 @@ export const MAX_WORKING_LINE_NORMALIZED_CODE_UNITS = 256;
|
|
|
40
40
|
export const MAX_WORKING_LINE_STYLE_TOKENS = 4;
|
|
41
41
|
export const MAX_WORKING_LINE_STYLE_CODE_UNITS = 48;
|
|
42
42
|
export const MAX_WORKING_LINE_ENTRIES_EXAMINED = 256;
|
|
43
|
+
/** Minimum interval between metric-only Loader replacements while an assistant streams. */
|
|
44
|
+
export const WORKING_LINE_METRIC_UPDATE_INTERVAL_MS = 50;
|
|
43
45
|
|
|
44
46
|
const WORKING_LINE_FALLBACKS: Record<"low" | "mid" | "high", SourceStyleFallback> = {
|
|
45
47
|
low: { theme: "dim", terminal: "bright-black" },
|
|
@@ -67,6 +69,16 @@ type WorkingLineContext = {
|
|
|
67
69
|
|
|
68
70
|
type AgentDurationListener = (durationMs: number) => void;
|
|
69
71
|
|
|
72
|
+
export type WorkingLineMetricScheduler = {
|
|
73
|
+
setTimeout(callback: () => void, delayMs: number): unknown;
|
|
74
|
+
clearTimeout(handle: unknown): void;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const defaultMetricScheduler: WorkingLineMetricScheduler = {
|
|
78
|
+
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
79
|
+
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
80
|
+
};
|
|
81
|
+
|
|
70
82
|
/** One agent-duration clock shared by minimalist Editor and Working-line consumers. */
|
|
71
83
|
export class AgentDurationClock {
|
|
72
84
|
private startedAt: number | undefined;
|
|
@@ -137,20 +149,36 @@ export class AgentDurationClock {
|
|
|
137
149
|
}
|
|
138
150
|
}
|
|
139
151
|
|
|
140
|
-
|
|
152
|
+
// Creating an `Intl.Segmenter` per call is very expensive (ICU init). The working
|
|
153
|
+
// line segments the same row for every animation frame, so reuse one instance.
|
|
154
|
+
let sharedGraphemeSegmenter: Intl.Segmenter | undefined;
|
|
155
|
+
let sharedGraphemeSegmenterResolved = false;
|
|
156
|
+
|
|
157
|
+
function getSharedGraphemeSegmenter(): Intl.Segmenter | undefined {
|
|
158
|
+
if (sharedGraphemeSegmenterResolved) return sharedGraphemeSegmenter;
|
|
159
|
+
sharedGraphemeSegmenterResolved = true;
|
|
141
160
|
try {
|
|
142
161
|
const Segmenter = Intl.Segmenter;
|
|
143
162
|
if (typeof Segmenter === "function") {
|
|
144
|
-
|
|
145
|
-
return {
|
|
146
|
-
*[Symbol.iterator]() {
|
|
147
|
-
for (const part of segments) yield part.segment;
|
|
148
|
-
},
|
|
149
|
-
};
|
|
163
|
+
sharedGraphemeSegmenter = new Segmenter(undefined, { granularity: "grapheme" });
|
|
150
164
|
}
|
|
151
165
|
} catch {
|
|
152
|
-
|
|
166
|
+
sharedGraphemeSegmenter = undefined;
|
|
167
|
+
}
|
|
168
|
+
return sharedGraphemeSegmenter;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function segmentGraphemes(value: string): Iterable<string> {
|
|
172
|
+
const segmenter = getSharedGraphemeSegmenter();
|
|
173
|
+
if (segmenter !== undefined) {
|
|
174
|
+
const segments = segmenter.segment(value);
|
|
175
|
+
return {
|
|
176
|
+
*[Symbol.iterator]() {
|
|
177
|
+
for (const part of segments) yield part.segment;
|
|
178
|
+
},
|
|
179
|
+
};
|
|
153
180
|
}
|
|
181
|
+
// Without Intl.Segmenter, treat the complete value as one conservative grapheme.
|
|
154
182
|
return [value];
|
|
155
183
|
}
|
|
156
184
|
|
|
@@ -763,6 +791,41 @@ function renderWorkingLineSchedule(
|
|
|
763
791
|
const frameStates: WorkingLineFrameState[] = [];
|
|
764
792
|
let codeUnits = 0;
|
|
765
793
|
const scheduleOrigin = definition.stateAt(scheduleStartFrame);
|
|
794
|
+
// `row` is invariant across the whole schedule; segment it once instead of per frame.
|
|
795
|
+
const rowCells = graphemeCells(row).cells;
|
|
796
|
+
// Many frames of the schedule share the same textTick/spinnerTick. Render each
|
|
797
|
+
// distinct value once and reuse — this dominates on slow CPUs (e.g. a Pi).
|
|
798
|
+
const textRenderCache = new Map<number, string>();
|
|
799
|
+
const renderTextForTick = (tick: number): string => {
|
|
800
|
+
let cachedText = textRenderCache.get(tick);
|
|
801
|
+
if (cachedText === undefined) {
|
|
802
|
+
cachedText = renderAnimatedText(
|
|
803
|
+
theme,
|
|
804
|
+
config,
|
|
805
|
+
colors,
|
|
806
|
+
rowCells,
|
|
807
|
+
width,
|
|
808
|
+
tick,
|
|
809
|
+
config.textAnimation,
|
|
810
|
+
);
|
|
811
|
+
textRenderCache.set(tick, cachedText);
|
|
812
|
+
}
|
|
813
|
+
return cachedText;
|
|
814
|
+
};
|
|
815
|
+
const spinnerRenderCache = new Map<number, string>();
|
|
816
|
+
const renderSpinnerForTick = (tick: number): string => {
|
|
817
|
+
// Key by the glyph index, not the raw tick: the glyph only repeats every
|
|
818
|
+
// `spinner.frames.length` ticks, so keying raw under-caches badly.
|
|
819
|
+
const frameCount = spinner.frames.length;
|
|
820
|
+
const index = ((tick % frameCount) + frameCount) % frameCount;
|
|
821
|
+
let cachedSpinner = spinnerRenderCache.get(index);
|
|
822
|
+
if (cachedSpinner === undefined) {
|
|
823
|
+
const glyph = spinner.frames[index] ?? spinner.frames[0];
|
|
824
|
+
cachedSpinner = renderTier(theme, config, colors, "high", glyph);
|
|
825
|
+
spinnerRenderCache.set(index, cachedSpinner);
|
|
826
|
+
}
|
|
827
|
+
return cachedSpinner;
|
|
828
|
+
};
|
|
766
829
|
for (let index = 0; index < definition.frameCount; index += 1) {
|
|
767
830
|
const scheduled = definition.stateAt(scheduleStartFrame + index);
|
|
768
831
|
const state = {
|
|
@@ -784,15 +847,7 @@ function renderWorkingLineSchedule(
|
|
|
784
847
|
state.textTick,
|
|
785
848
|
config.textAnimation,
|
|
786
849
|
)}${SGR_RESET}`
|
|
787
|
-
: `${
|
|
788
|
-
theme,
|
|
789
|
-
config,
|
|
790
|
-
colors,
|
|
791
|
-
graphemeCells(row).cells,
|
|
792
|
-
width,
|
|
793
|
-
state.textTick,
|
|
794
|
-
config.textAnimation,
|
|
795
|
-
)}${SGR_RESET}`;
|
|
850
|
+
: `${renderSpinnerForTick(state.spinnerTick)} ${renderTextForTick(state.textTick)}${SGR_RESET}`;
|
|
796
851
|
codeUnits += frame.length;
|
|
797
852
|
if (codeUnits > MAX_WORKING_LINE_FRAME_CODE_UNITS) return undefined;
|
|
798
853
|
frames.push(frame);
|
|
@@ -1051,6 +1106,11 @@ export class WorkingLineController {
|
|
|
1051
1106
|
private elapsedUpdatesContext: WorkingLineContext | undefined;
|
|
1052
1107
|
private elapsedUpdatesGeneration = 0;
|
|
1053
1108
|
private stopElapsedUpdates: (() => void) | undefined;
|
|
1109
|
+
private lastMetricWriteAt: number | undefined;
|
|
1110
|
+
private metricUpdateScheduled = false;
|
|
1111
|
+
private metricUpdateHandle: unknown;
|
|
1112
|
+
private metricUpdateContext: WorkingLineContext | undefined;
|
|
1113
|
+
private metricUpdateGeneration = 0;
|
|
1054
1114
|
|
|
1055
1115
|
constructor(
|
|
1056
1116
|
private readonly getConfig: () => ZentuiConfig,
|
|
@@ -1060,6 +1120,7 @@ export class WorkingLineController {
|
|
|
1060
1120
|
private readonly now: () => number = Date.now,
|
|
1061
1121
|
private readonly getThought: () => WorkingLineRuntimeSegments["thought"] = () => this.thought,
|
|
1062
1122
|
private readonly onUnavailable: () => void = () => {},
|
|
1123
|
+
private readonly metricScheduler: WorkingLineMetricScheduler = defaultMetricScheduler,
|
|
1063
1124
|
) {}
|
|
1064
1125
|
|
|
1065
1126
|
startSession(ctx: WorkingLineContext): WorkingLineReconcileResult {
|
|
@@ -1082,6 +1143,7 @@ export class WorkingLineController {
|
|
|
1082
1143
|
}
|
|
1083
1144
|
|
|
1084
1145
|
startTurn(ctx: WorkingLineContext): WorkingLineReconcileResult {
|
|
1146
|
+
this.lastMetricWriteAt = undefined;
|
|
1085
1147
|
const config = this.getConfig().components.workingLine;
|
|
1086
1148
|
this.activeTools.clear();
|
|
1087
1149
|
const selectedMessage = selectWorkingLineMessage(config, this.random);
|
|
@@ -1099,12 +1161,15 @@ export class WorkingLineController {
|
|
|
1099
1161
|
): void {
|
|
1100
1162
|
this.tokens = tokens;
|
|
1101
1163
|
this.thought = thought;
|
|
1102
|
-
this.
|
|
1164
|
+
this.scheduleMetricIndicator(ctx);
|
|
1103
1165
|
this.reconcileElapsedUpdates(ctx);
|
|
1104
1166
|
}
|
|
1105
1167
|
|
|
1106
1168
|
updateTokens(tokens: WorkingLineRuntimeSegments["tokens"], ctx: WorkingLineContext): void {
|
|
1107
|
-
this.
|
|
1169
|
+
this.tokens = tokens;
|
|
1170
|
+
this.thought = this.getThought();
|
|
1171
|
+
this.updateIndicator(ctx);
|
|
1172
|
+
this.reconcileElapsedUpdates(ctx);
|
|
1108
1173
|
}
|
|
1109
1174
|
|
|
1110
1175
|
updateExtensionSegments(segments: readonly string[], ctx: WorkingLineContext): boolean {
|
|
@@ -1143,7 +1208,7 @@ export class WorkingLineController {
|
|
|
1143
1208
|
this.updateIndicator(ctx);
|
|
1144
1209
|
}
|
|
1145
1210
|
|
|
1146
|
-
|
|
1211
|
+
flushMetrics(
|
|
1147
1212
|
tokens: WorkingLineRuntimeSegments["tokens"],
|
|
1148
1213
|
thought: WorkingLineRuntimeSegments["thought"],
|
|
1149
1214
|
ctx: WorkingLineContext,
|
|
@@ -1154,6 +1219,14 @@ export class WorkingLineController {
|
|
|
1154
1219
|
this.reconcileElapsedUpdates(ctx);
|
|
1155
1220
|
}
|
|
1156
1221
|
|
|
1222
|
+
settle(
|
|
1223
|
+
tokens: WorkingLineRuntimeSegments["tokens"],
|
|
1224
|
+
thought: WorkingLineRuntimeSegments["thought"],
|
|
1225
|
+
ctx: WorkingLineContext,
|
|
1226
|
+
): void {
|
|
1227
|
+
this.flushMetrics(tokens, thought, ctx);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1157
1230
|
reconcile(ctx: WorkingLineContext): WorkingLineReconcileResult {
|
|
1158
1231
|
const config = this.getConfig().components.workingLine;
|
|
1159
1232
|
if (!config.enabled) {
|
|
@@ -1195,6 +1268,7 @@ export class WorkingLineController {
|
|
|
1195
1268
|
rebasePhase = false,
|
|
1196
1269
|
selectedMessage?: string,
|
|
1197
1270
|
): WorkingLineReconcileResult {
|
|
1271
|
+
this.cancelMetricIndicator();
|
|
1198
1272
|
const ui = workingLineUi(ctx);
|
|
1199
1273
|
if (!ui) {
|
|
1200
1274
|
this.installed = false;
|
|
@@ -1350,6 +1424,7 @@ export class WorkingLineController {
|
|
|
1350
1424
|
}
|
|
1351
1425
|
|
|
1352
1426
|
private updateIndicator(ctx: WorkingLineContext): boolean {
|
|
1427
|
+
this.cancelMetricIndicator();
|
|
1353
1428
|
const rootConfig = this.getConfig();
|
|
1354
1429
|
if (!rootConfig.components.workingLine.enabled || !this.installed) return false;
|
|
1355
1430
|
const ui = workingLineUi(ctx);
|
|
@@ -1365,6 +1440,49 @@ export class WorkingLineController {
|
|
|
1365
1440
|
}
|
|
1366
1441
|
}
|
|
1367
1442
|
|
|
1443
|
+
private scheduleMetricIndicator(ctx: WorkingLineContext): void {
|
|
1444
|
+
const config = this.getConfig().components.workingLine;
|
|
1445
|
+
if (!config.enabled || !this.installed) return;
|
|
1446
|
+
this.metricUpdateContext = ctx;
|
|
1447
|
+
const sampledAt = this.now();
|
|
1448
|
+
const elapsed =
|
|
1449
|
+
this.lastMetricWriteAt === undefined
|
|
1450
|
+
? Number.POSITIVE_INFINITY
|
|
1451
|
+
: Math.max(0, sampledAt - this.lastMetricWriteAt);
|
|
1452
|
+
if (elapsed >= WORKING_LINE_METRIC_UPDATE_INTERVAL_MS) {
|
|
1453
|
+
this.updateIndicator(ctx);
|
|
1454
|
+
this.lastMetricWriteAt = sampledAt;
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (this.metricUpdateScheduled) return;
|
|
1458
|
+
this.metricUpdateScheduled = true;
|
|
1459
|
+
const generation = ++this.metricUpdateGeneration;
|
|
1460
|
+
this.metricUpdateHandle = this.metricScheduler.setTimeout(
|
|
1461
|
+
() => {
|
|
1462
|
+
if (!this.metricUpdateScheduled || generation !== this.metricUpdateGeneration) return;
|
|
1463
|
+
this.metricUpdateScheduled = false;
|
|
1464
|
+
this.metricUpdateHandle = undefined;
|
|
1465
|
+
const latest = this.metricUpdateContext;
|
|
1466
|
+
this.metricUpdateContext = undefined;
|
|
1467
|
+
if (latest) {
|
|
1468
|
+
this.updateIndicator(latest);
|
|
1469
|
+
this.lastMetricWriteAt = this.now();
|
|
1470
|
+
}
|
|
1471
|
+
},
|
|
1472
|
+
Math.max(0, WORKING_LINE_METRIC_UPDATE_INTERVAL_MS - elapsed),
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
private cancelMetricIndicator(): void {
|
|
1477
|
+
this.metricUpdateContext = undefined;
|
|
1478
|
+
this.metricUpdateGeneration++;
|
|
1479
|
+
if (this.metricUpdateScheduled) {
|
|
1480
|
+
this.metricScheduler.clearTimeout(this.metricUpdateHandle);
|
|
1481
|
+
}
|
|
1482
|
+
this.metricUpdateScheduled = false;
|
|
1483
|
+
this.metricUpdateHandle = undefined;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1368
1486
|
private reconcileElapsedUpdates(ctx: WorkingLineContext): void {
|
|
1369
1487
|
this.elapsedUpdatesContext = ctx;
|
|
1370
1488
|
const config = this.getConfig().components.workingLine;
|
|
@@ -1473,6 +1591,7 @@ export class WorkingLineController {
|
|
|
1473
1591
|
}
|
|
1474
1592
|
|
|
1475
1593
|
private reset(ctx: WorkingLineContext): void {
|
|
1594
|
+
this.cancelMetricIndicator();
|
|
1476
1595
|
this.deactivateElapsedUpdates();
|
|
1477
1596
|
if (!this.ownsIndicator && !this.ownsMessage) return;
|
|
1478
1597
|
const ui = workingLineUi(ctx);
|
|
@@ -1499,6 +1618,8 @@ export class WorkingLineController {
|
|
|
1499
1618
|
}
|
|
1500
1619
|
|
|
1501
1620
|
private clearRuntime(): void {
|
|
1621
|
+
this.cancelMetricIndicator();
|
|
1622
|
+
this.lastMetricWriteAt = undefined;
|
|
1502
1623
|
this.deactivateElapsedUpdates();
|
|
1503
1624
|
this.agentActive = false;
|
|
1504
1625
|
this.activeTools.clear();
|